mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
Compare commits
100 Commits
link-thumb
...
n3-p8-enem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c067452e | ||
|
|
0d18cea59a | ||
|
|
88c5fcdf2f | ||
|
|
d5fecf45d8 | ||
|
|
6be7744e81 | ||
|
|
1f1fbf0251 | ||
|
|
d7a5333048 | ||
|
|
91eeee0826 | ||
|
|
c34e740008 | ||
|
|
a59a544fff | ||
|
|
d76c63be0c | ||
|
|
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 | ||
|
|
2ea3e42612 | ||
|
|
3ed2e1d8e0 |
49
.env.example
49
.env.example
@@ -1,9 +1,42 @@
|
||||
# ---- 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
|
||||
|
||||
@@ -128,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]
|
||||
//
|
||||
// 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
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) {
|
||||
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 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) {
|
||||
|
||||
20
go.mod
20
go.mod
@@ -11,9 +11,10 @@ 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
|
||||
maunium.net/go/mautrix v0.28.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
@@ -21,26 +22,27 @@ 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
|
||||
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.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
47
go.sum
47
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,23 +148,24 @@ 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=
|
||||
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.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=
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
devicePath := filepath.Join(cfg.DataDir, "device.json")
|
||||
|
||||
// Try to load existing device credentials
|
||||
device, err := loadDevice(devicePath)
|
||||
if err != nil {
|
||||
slog.Info("no existing device found, will login fresh")
|
||||
if cfg.UserID == "" {
|
||||
return nil, fmt.Errorf("BOT_USER_ID is required")
|
||||
}
|
||||
|
||||
var client *mautrix.Client
|
||||
ctx := context.Background()
|
||||
|
||||
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)
|
||||
// ---- 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)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(device.DeviceID)
|
||||
|
||||
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 {
|
||||
slog.Warn("existing device credentials invalid, logging in again")
|
||||
device = nil
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
userID := id.UserID(cfg.UserID)
|
||||
client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(loginResp.DeviceID)
|
||||
client.DeviceID = id.DeviceID(auth.deviceID)
|
||||
|
||||
// Save device info
|
||||
device = &DeviceInfo{
|
||||
AccessToken: loginResp.AccessToken,
|
||||
DeviceID: loginResp.DeviceID,
|
||||
UserID: loginResp.UserID,
|
||||
// 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 err := saveDevice(devicePath, device); err != nil {
|
||||
slog.Warn("failed to save device info", "err", err)
|
||||
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)
|
||||
|
||||
slog.Info("logged in successfully",
|
||||
"user_id", loginResp.UserID,
|
||||
"device_id", loginResp.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
|
||||
}
|
||||
var info DeviceInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
return &info, nil
|
||||
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):
|
||||
}
|
||||
|
||||
func saveDevice(path string, info *DeviceInfo) error {
|
||||
data, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
continue
|
||||
}
|
||||
client.AccessToken = auth.token()
|
||||
slog.Debug("MAS token refreshed", "expires_at", auth.expiry().Format(time.RFC3339))
|
||||
}
|
||||
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"
|
||||
@@ -11,11 +13,22 @@ import (
|
||||
type Registry struct {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,27 @@ func runMigrations(d *sql.DB) error {
|
||||
// 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 {
|
||||
@@ -1500,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,
|
||||
@@ -2001,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
|
||||
@@ -2031,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,6 +28,7 @@ type AdventurePlugin struct {
|
||||
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)
|
||||
@@ -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
|
||||
@@ -248,9 +270,13 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.expeditionRecapTicker()
|
||||
go p.expeditionAmbientTicker()
|
||||
go p.expeditionAutoRunTicker()
|
||||
go p.expeditionExtractionSweepTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
// Extractions that lapsed while the bot was down: the roster they hold is
|
||||
// blocking those members right now, not an hour from now.
|
||||
p.sweepLapsedExtractions(time.Now().UTC())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -279,6 +305,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 +357,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 +503,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 +539,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 +729,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 +875,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 +898,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 +1101,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 +1573,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.")
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -134,13 +134,13 @@ type ArenaLeaderboardEntry struct {
|
||||
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"+
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,78 @@ 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.
|
||||
//
|
||||
// K — the three social/economic anchors miss the pure grind loop: a player who
|
||||
// only mines/forages/fishes (now automatic, done by walking) and clears
|
||||
// single-day dungeons, but never sells, never enters the arena, and never runs
|
||||
// a multi-day expedition to a Night camp, would roll for zero events. The
|
||||
// fourth anchor, ZoneClear, fires on a foreground single-day zone clear — the
|
||||
// climax DM that player *is* reading — so they get the same rough cadence.
|
||||
// Autopilot walks and mid-zone region clears deliberately do not fire it (see
|
||||
// zoneCmdAdvance / the full-clear branch of advanceOnceWithOpts). The per-day
|
||||
// slot still caps everyone at one event, so a player who happens to hit two
|
||||
// anchors in a day can't double up. This rate is the tuning knob for item K —
|
||||
// bump it if telemetry shows the grind-loop player still sees too few.
|
||||
const (
|
||||
advEventChanceDigest = 0.08
|
||||
advEventChanceSell = 0.05
|
||||
advEventChanceArena = 0.05
|
||||
advEventChanceZoneClear = 0.08
|
||||
)
|
||||
|
||||
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 +106,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 +153,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 +162,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 +185,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
})
|
||||
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleEventRespond processes `!adventure respond`.
|
||||
|
||||
120
internal/plugin/adventure_events_test.go
Normal file
120
internal/plugin/adventure_events_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneClearAnchorRate pins item K's fourth anchor for the population the
|
||||
// other three miss: a grind-loop player who never sells, never enters the
|
||||
// arena, and never runs a multi-day expedition, but clears single-day zones.
|
||||
// Modelled at two foreground clears per active day (a plausible dungeon
|
||||
// session) they should land in the same ~1 event/week band as the fully-
|
||||
// engaged player — that is the whole point of adding the anchor. The clears/day
|
||||
// assumption and advEventChanceZoneClear are the two tuning knobs; see the K
|
||||
// note in adventure_events.go.
|
||||
func TestZoneClearAnchorRate(t *testing.T) {
|
||||
const clearsPerDay = 2
|
||||
|
||||
const weeks = 20000
|
||||
rng := rand.New(rand.NewPCG(0x5EED, 0x2C)) // "ZC"
|
||||
events := 0
|
||||
for range weeks {
|
||||
for range 7 {
|
||||
firedToday := false
|
||||
for range clearsPerDay {
|
||||
if firedToday {
|
||||
break // one event per player per UTC day
|
||||
}
|
||||
if rng.Float64() < advEventChanceZoneClear {
|
||||
firedToday = true
|
||||
}
|
||||
}
|
||||
if firedToday {
|
||||
events++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perWeek := float64(events) / weeks
|
||||
if perWeek < 0.8 || perWeek > 1.5 {
|
||||
t.Errorf("zone-clear anchor = %.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_",
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -401,18 +435,8 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
|
||||
return p.SendDM(ctx.Sender, "Equip cancelled.")
|
||||
}
|
||||
|
||||
// Parse number
|
||||
idx := 0
|
||||
for _, c := range reply {
|
||||
if c >= '0' && c <= '9' {
|
||||
idx = idx*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
idx-- // 1-indexed to 0-indexed
|
||||
|
||||
if idx < 0 || idx >= len(data.Items) {
|
||||
idx, ok := parseMenuIndex(reply, len(data.Items))
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,25 @@ func petShouldArrive(pet PetState, house HouseState) bool {
|
||||
return rand.Float64() < 0.30
|
||||
}
|
||||
|
||||
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
|
||||
// surfaces from an expedition (voluntary extract, abandon, or a survived
|
||||
// forced extraction) or revives after death. The arrival roll lives on the
|
||||
// emergence seam — not the legacy 08:00 overworld morning DM — because
|
||||
// expedition players are almost never in the overworld at the scheduled hour,
|
||||
// so the morning roll never reached them. Story beat: while the player was
|
||||
// underground, an animal wandered into the empty house looking for food.
|
||||
//
|
||||
// Safe to call unconditionally on any emergence: petShouldArrive gates on
|
||||
// house tier / not-yet-arrived, and petArrivalDM won't clobber an existing
|
||||
// pending interaction.
|
||||
func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
|
||||
pet, _ := loadPetState(userID)
|
||||
house, _ := loadHouseState(userID)
|
||||
if petShouldArrive(pet, house) {
|
||||
p.petArrivalDM(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
||||
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
|
||||
// Don't overwrite an existing pending interaction
|
||||
@@ -310,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()
|
||||
@@ -321,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)
|
||||
}
|
||||
}
|
||||
@@ -920,7 +920,6 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
|
||||
// Standout
|
||||
if bestPlayer != nil && bestPlayer.LootValue > 0 {
|
||||
pool := SummaryStandoutGood
|
||||
|
||||
@@ -82,6 +82,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
// Emergence seam (death case): a player who died underground
|
||||
// "comes home" on respawn. This is the deferred half of the
|
||||
// emergence roll — survived extractions roll at their exit
|
||||
// site; deaths roll here. See maybeRollPetArrivalOnEmerge.
|
||||
p.maybeRollPetArrivalOnEmerge(char.UserID)
|
||||
}
|
||||
|
||||
// Babysitting: pet-care trickle (no harvest actions; safe-rest perk
|
||||
@@ -133,13 +139,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pet arrival check (fires before normal morning DM)
|
||||
house, _ := loadHouseState(char.UserID)
|
||||
// Pet arrival no longer rolls here. The 08:00 overworld morning DM
|
||||
// is skipped for anyone underground (expedition gate above), so it
|
||||
// never reached expedition players. Arrival now fires on the
|
||||
// emergence seam — see maybeRollPetArrivalOnEmerge, called from the
|
||||
// extract/abandon/forced-extract and respawn paths.
|
||||
pet, _ := loadPetState(char.UserID)
|
||||
if petShouldArrive(pet, house) {
|
||||
p.petArrivalDM(char.UserID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Morning pet event
|
||||
petEvent := petMorningEvent(pet)
|
||||
@@ -384,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
|
||||
}
|
||||
@@ -391,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)
|
||||
@@ -400,36 +407,39 @@ 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).
|
||||
// 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 {
|
||||
// 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
|
||||
|
||||
if engaged {
|
||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else {
|
||||
// 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 {
|
||||
@@ -440,13 +450,33 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Jitter between DMs to avoid Matrix rate limits
|
||||
// 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++
|
||||
|
||||
// Idle shame DM
|
||||
text := renderAdvIdleShameDM(char.UserID)
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
@@ -461,19 +491,6 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
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 char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else {
|
||||
// Gap in activity — start fresh
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all daily actions — retry up to 3 times to handle SQLite busy errors
|
||||
@@ -490,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")
|
||||
}
|
||||
}
|
||||
410
internal/plugin/adventure_temper.go
Normal file
410
internal/plugin/adventure_temper.go
Normal file
@@ -0,0 +1,410 @@
|
||||
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
|
||||
}
|
||||
|
||||
// parseMenuIndex reads a leading 1-indexed number off a menu reply and returns
|
||||
// the bounds-checked 0-indexed position. The single parser behind every "reply
|
||||
// with a number from the list" prompt — temper, magic-item equip, masterwork
|
||||
// equip. Leading whitespace and any trailing text after the digits are ignored;
|
||||
// a reply with no leading digit, or one outside [1, n], returns ok=false.
|
||||
func parseMenuIndex(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 := parseMenuIndex(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 := parseMenuIndex(tc.in, tc.n)
|
||||
if ok != tc.wantOK || (ok && got != tc.want) {
|
||||
t.Errorf("parseMenuIndex(%q, %d) = (%d, %v), want (%d, %v)",
|
||||
tc.in, tc.n, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
294
internal/plugin/combat_armed_ability_test.go
Normal file
294
internal/plugin/combat_armed_ability_test.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// An armed ability is consumed once, at fight start, and its id is parked on the
|
||||
// seat. Everything here defends that split.
|
||||
//
|
||||
// Before it existed, buildZoneCombatants consumed the ability itself — and the
|
||||
// turn-based engine calls that builder again on every !attack. So a Berserker
|
||||
// raged on round 1, the builder cleared their armed flag and saved, and round 2
|
||||
// rebuilt them with no rage at all. They paid stamina for one round of a buff
|
||||
// that is supposed to span the fight, and the close-out could not see the rage
|
||||
// it was supposed to charge exhaustion for.
|
||||
|
||||
// ragingBerserker is a Fighter who has already `!arm`ed rage.
|
||||
func ragingBerserker(t *testing.T, uid id.UserID) *DnDCharacter {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, string(uid)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassBerserker, Level: 5,
|
||||
STR: 16, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
|
||||
ArmedAbility: "rage",
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ── the split ────────────────────────────────────────────────────────────────
|
||||
|
||||
// consumeArmedAbility disarms and reports; applyAbilityByID applies and does not
|
||||
// disarm. Calling the second one twice must be indistinguishable from once.
|
||||
func TestApplyAbilityByID_IsPureAndRepeatable(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
c := ragingBerserker(t, "@pure:example.org")
|
||||
|
||||
armed := consumeArmedAbility(c)
|
||||
if armed != "rage" {
|
||||
t.Fatalf("consumeArmedAbility = %q, want rage", armed)
|
||||
}
|
||||
if c.ArmedAbility != "" {
|
||||
t.Errorf("character still armed after consume: %q", c.ArmedAbility)
|
||||
}
|
||||
if again := consumeArmedAbility(c); again != "" {
|
||||
t.Errorf("second consume returned %q, want \"\" — the ability is spent", again)
|
||||
}
|
||||
|
||||
// Same id, applied to two independent mod sets, yields the same rage.
|
||||
for i, want := range []bool{true, true} {
|
||||
var mods CombatModifiers
|
||||
name, fired := applyAbilityByID(c, armed, &mods)
|
||||
if !fired || name != "Rage" {
|
||||
t.Fatalf("apply #%d: fired=%v name=%q", i+1, fired, name)
|
||||
}
|
||||
if mods.BerserkerRage != want {
|
||||
t.Errorf("apply #%d: BerserkerRage = %v, want %v", i+1, mods.BerserkerRage, want)
|
||||
}
|
||||
if mods.RageMeleeDmg != 2 || !mods.PhysicalResistRage {
|
||||
t.Errorf("apply #%d: rage did not carry its full mod set: %+v", i+1, mods)
|
||||
}
|
||||
}
|
||||
|
||||
// And it never writes back to the sheet.
|
||||
got, _ := LoadDnDCharacter(c.UserID)
|
||||
if got.ArmedAbility != "" {
|
||||
t.Errorf("applyAbilityByID re-armed the sheet: %q", got.ArmedAbility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAbilityByID_UnknownAndEmptyAreNoOps(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter}
|
||||
for _, id := range []string{"", "no_such_ability"} {
|
||||
var mods CombatModifiers
|
||||
if _, fired := applyAbilityByID(c, id, &mods); fired {
|
||||
t.Errorf("applyAbilityByID(%q) fired", id)
|
||||
}
|
||||
if mods.BerserkerRage {
|
||||
t.Errorf("applyAbilityByID(%q) set rage", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the fight-start seat ─────────────────────────────────────────────────────
|
||||
|
||||
// The seat carries the id forward, and the sheet is disarmed exactly once.
|
||||
func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@berserk:example.org")
|
||||
ragingBerserker(t, uid)
|
||||
|
||||
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
if len(seats) != 1 {
|
||||
t.Fatalf("seats = %d, want 1", len(seats))
|
||||
}
|
||||
if seats[0].ArmedAbility != "rage" {
|
||||
t.Errorf("seat.ArmedAbility = %q, want rage", seats[0].ArmedAbility)
|
||||
}
|
||||
if !seats[0].Mods.BerserkerRage {
|
||||
t.Error("seat built without rage in its mods")
|
||||
}
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.ArmedAbility != "" {
|
||||
t.Errorf("sheet still armed after fight start: %q", got.ArmedAbility)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression itself: rebuilding the combatant — what every !attack does —
|
||||
// must reproduce the rage from the persisted id, not from the (now cleared)
|
||||
// sheet. Two rebuilds in a row, both raging.
|
||||
func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@rebuild:example.org")
|
||||
ragingBerserker(t, uid)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
armed := seats[0].ArmedAbility
|
||||
|
||||
for round := 2; round <= 3; round++ {
|
||||
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, armed)
|
||||
if err != nil {
|
||||
t.Fatalf("round %d rebuild: %v", round, err)
|
||||
}
|
||||
if !player.Mods.BerserkerRage {
|
||||
t.Errorf("round %d: rage evaporated on rebuild", round)
|
||||
}
|
||||
if player.Mods.RageMeleeDmg != 2 {
|
||||
t.Errorf("round %d: RageMeleeDmg = %d, want 2", round, player.Mods.RageMeleeDmg)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat that armed nothing rebuilds without rage — the id is the only source.
|
||||
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if player.Mods.BerserkerRage {
|
||||
t.Error("unarmed rebuild raged anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// A member who is down sits the fight out. They must not be charged the ability
|
||||
// they had readied for it — the refusal is checked before the arm.
|
||||
func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
downed := id.UserID("@downed:example.org")
|
||||
fightTestChar(t, leader, 30)
|
||||
ragingBerserker(t, downed)
|
||||
|
||||
// Drop the member to 0 HP with rage still armed.
|
||||
c, _ := LoadDnDCharacter(downed)
|
||||
c.HPCurrent = 0
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
if len(seats) != 1 || seats[0].UserID != leader {
|
||||
t.Fatalf("seats = %+v, want the leader alone", seats)
|
||||
}
|
||||
got, _ := LoadDnDCharacter(downed)
|
||||
if got.ArmedAbility != "rage" {
|
||||
t.Errorf("downed member was disarmed for a fight they never joined: %q", got.ArmedAbility)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the close-out ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSeatFightStartMods_ReadsTheRageOffTheSeatsStatuses(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 5}
|
||||
|
||||
raging := &CombatSession{}
|
||||
raging.Statuses.ArmedAbility = "rage"
|
||||
if !seatFightStartMods(raging, 0, c).BerserkerRage {
|
||||
t.Error("seat 0 armed rage, mods say otherwise")
|
||||
}
|
||||
|
||||
// Seat 1 reads its own statuses, not the leader's.
|
||||
party := &CombatSession{Participants: []CombatParticipant{{Seat: 1}}}
|
||||
party.Statuses.ArmedAbility = "rage"
|
||||
if seatFightStartMods(party, 1, c).BerserkerRage {
|
||||
t.Error("a member inherited the leader's rage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeatCombatResult_ReadsWinAndNearDeathOffTheSession(t *testing.T) {
|
||||
sess := &CombatSession{
|
||||
Status: CombatStatusWon, Round: 4, PlayerHP: 5, PlayerHPMax: 40, EnemyHP: 0,
|
||||
TurnLog: []CombatEvent{
|
||||
{Action: "attack", Seat: 0},
|
||||
{Action: "use_consumable", Seat: 1},
|
||||
},
|
||||
}
|
||||
got := seatCombatResult(sess, 0)
|
||||
if !got.PlayerWon {
|
||||
t.Error("PlayerWon = false on a won session")
|
||||
}
|
||||
if !got.NearDeath {
|
||||
t.Error("5/40 HP is under the 15% near-death line")
|
||||
}
|
||||
if len(got.Events) != 1 || got.Events[0].Action != "attack" {
|
||||
t.Errorf("seat 0 got seat 1's events: %+v", got.Events)
|
||||
}
|
||||
|
||||
sess.PlayerHP = 30
|
||||
if seatCombatResult(sess, 0).NearDeath {
|
||||
t.Error("30/40 HP is not near death")
|
||||
}
|
||||
sess.Status = CombatStatusLost
|
||||
sess.PlayerHP = 0
|
||||
if seatCombatResult(sess, 0).PlayerWon {
|
||||
t.Error("PlayerWon = true on a lost session")
|
||||
}
|
||||
}
|
||||
|
||||
// Item A: the manual kill now costs the Berserker their exhaustion, exactly as
|
||||
// the auto-resolved one always has.
|
||||
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnTheTurnBasedPath(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@exhausted:example.org")
|
||||
ragingBerserker(t, uid)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 3,
|
||||
PlayerHP: 12, PlayerHPMax: 40,
|
||||
}
|
||||
sess.Statuses.ArmedAbility = "rage"
|
||||
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Exhaustion != 1 {
|
||||
t.Errorf("Exhaustion = %d after a raging win, want 1", got.Exhaustion)
|
||||
}
|
||||
}
|
||||
|
||||
// A fight nobody raged in leaves the sheet alone.
|
||||
func TestPostCombatBookkeepingForSeat_NoRageNoExhaustion(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@calm:example.org")
|
||||
fightTestChar(t, uid, 30)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 2,
|
||||
PlayerHP: 20, PlayerHPMax: 30,
|
||||
}
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Exhaustion != 0 {
|
||||
t.Errorf("Exhaustion = %d with no rage, want 0", got.Exhaustion)
|
||||
}
|
||||
}
|
||||
|
||||
// Losing while raging still exhausts you — the close-out runs on every terminal
|
||||
// status, not just the win. This is the half the turn-based path used to skip.
|
||||
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnALoss(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@dead:example.org")
|
||||
ragingBerserker(t, uid)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusLost, Round: 6,
|
||||
PlayerHP: 0, PlayerHPMax: 40,
|
||||
}
|
||||
sess.Statuses.ArmedAbility = "rage"
|
||||
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Exhaustion != 1 {
|
||||
t.Errorf("Exhaustion = %d after a raging loss, want 1", got.Exhaustion)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,30 @@ import (
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// postCombatBookkeeping is the close-out every fight owes its character,
|
||||
// whatever surface it was fought on: achievements, and the subclass state that
|
||||
// outlives the fight (Berserker exhaustion, Grim Harvest's kill-heal).
|
||||
//
|
||||
// It exists because there are four close-outs — two auto-resolve
|
||||
// (runDungeonCombat, runZoneCombatRoster) and two turn-based
|
||||
// (finishCombatSession, finishPartyCombatSession) — and for a long time only
|
||||
// the auto-resolve pair ran any of this. The same Elite kill therefore paid out
|
||||
// differently depending on whether the player let it auto-resolve or fought it
|
||||
// a round at a time. Route all four through here and the divergence cannot
|
||||
// silently reopen.
|
||||
//
|
||||
// raged is whether the character's Berserker rage was active for this fight;
|
||||
// mods carries the fight-start modifiers Grim Harvest reads. HP persistence is
|
||||
// NOT done here — the turn-based paths already own their own HP writes.
|
||||
func (p *AdventurePlugin) postCombatBookkeeping(
|
||||
userID id.UserID, dndChar *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers,
|
||||
) {
|
||||
p.grantCombatAchievements(userID, result)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||||
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
||||
if p.achievements == nil {
|
||||
@@ -87,7 +111,7 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||
}
|
||||
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
||||
@@ -129,12 +153,8 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
// until a player-driven use command lands.
|
||||
consumeFiredHealingItems(userID, countHealEventsFired(result))
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err)
|
||||
}
|
||||
p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods)
|
||||
|
||||
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
@@ -592,4 +612,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,37 @@ 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())
|
||||
// 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)
|
||||
}
|
||||
// The persisted session scales enemy HP for a party (solo scales by 1.0, so
|
||||
// this is a no-op there); mirror it here so the entry banner and the opening
|
||||
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||||
// the rebuilt rounds use.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
|
||||
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
|
||||
if playerHP <= 0 {
|
||||
return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.")
|
||||
}
|
||||
enemyHP := enemy.Stats.MaxHP
|
||||
|
||||
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
|
||||
// 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 +131,57 @@ 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)
|
||||
// Align the in-memory template with the scaled HP persisted above, so the
|
||||
// opening-round settle resolves the enemy against the same MaxHP the
|
||||
// rebuilt rounds do (regen clamp, bloodied-ability threshold). The persist
|
||||
// already happened off the unscaled value, so this does not double-scale.
|
||||
enemy.Stats.MaxHP = enemyHP
|
||||
// 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 +194,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 +251,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,12 +319,32 @@ 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
|
||||
// the room's manual combat is done, and a fresh !zone advance clears the room.
|
||||
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
|
||||
persistDnDHPAfterCombat(userID, sess.PlayerHP)
|
||||
// Achievements and post-combat subclass state are owed on every terminal
|
||||
// status, not just a win — a Berserker who rages and loses still comes out
|
||||
// of it exhausted. The auto-resolve close-outs have always done this.
|
||||
p.postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
run, _ := getZoneRun(sess.RunID)
|
||||
var zone ZoneDefinition
|
||||
@@ -228,26 +363,12 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
var b strings.Builder
|
||||
switch sess.Status {
|
||||
case CombatStatusWon:
|
||||
recordZoneKillForUser(userID, sess.EnemyID)
|
||||
applyRoomCombatThreatForUser(userID, elite)
|
||||
// zoneCombatXP only reads PlayerWon + NearDeath off the result.
|
||||
nearDeath := sess.PlayerHPMax > 0 && sess.PlayerHP*5 < sess.PlayerHPMax
|
||||
tier := 1
|
||||
if run != nil {
|
||||
tier = int(zone.Tier)
|
||||
}
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
if !elite {
|
||||
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
|
||||
// for standalone zone runs (no active expedition).
|
||||
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
}
|
||||
}
|
||||
bossOnExpedition := p.applyOwnerWinEffects(userID, sess.EnemyID, elite)
|
||||
p.grantSeatWinXP(userID, sess.PlayerHP, sess.PlayerHPMax, monster, tier)
|
||||
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
@@ -257,17 +378,21 @@ 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 {
|
||||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||||
}
|
||||
_ = abandonZoneRun(userID)
|
||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
||||
endRunOnLoss(userID, sess.RunID, true)
|
||||
markAdventureDead(userID, "zone", zone.Display)
|
||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
@@ -278,8 +403,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
// Flee = run ends, light penalty: wounds persist (HP already saved),
|
||||
// but no death timer. Chosen candidate from the migration plan's
|
||||
// open question on flee outcome.
|
||||
_ = abandonZoneRun(userID)
|
||||
forceExtractExpeditionForRunLoss(userID, "combat flee")
|
||||
endRunOnLoss(userID, sess.RunID, false)
|
||||
b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||||
|
||||
default:
|
||||
@@ -367,65 +491,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())
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+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.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 +582,25 @@ 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
|
||||
}
|
||||
// Park the Necromancy kill-heal stash on the casting seat. The
|
||||
// auto-resolve path keeps it on the fight-start CombatModifiers, which
|
||||
// a turn-based fight has nowhere to hold — it rebuilds its combatants
|
||||
// every round. Only a damaging cast stashes (a miss leaves the slot at
|
||||
// 0), and each one overwrites the last, so the stash always describes
|
||||
// the seat's most recent landed spell — which is the only one that can
|
||||
// have been lethal by the time the close-out reads it.
|
||||
if out.GrimHarvestSlot > 0 {
|
||||
as := ct.sess.actorStatusesPtr(seat)
|
||||
as.GrimHarvestSlot = out.GrimHarvestSlot
|
||||
as.GrimHarvestNecrotic = out.GrimHarvestNecrotic
|
||||
}
|
||||
eff = &turnActionEffect{
|
||||
Label: out.Desc,
|
||||
@@ -451,16 +609,27 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
||||
PlayerHeal: out.PlayerHeal,
|
||||
EnemySkip: out.EnemySkip,
|
||||
}
|
||||
// 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 PlayerAction{Kind: ActionCast, Effect: eff}, refund, ""
|
||||
}
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
|
||||
// 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 {
|
||||
if spell.Level > 0 {
|
||||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||
return err
|
||||
}
|
||||
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))
|
||||
ct.players, ct.enemy = players, enemy
|
||||
return nil
|
||||
}
|
||||
|
||||
// chargeSpellCost debits a spell's material component and leveled slot for a
|
||||
@@ -525,45 +694,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 +768,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
|
||||
}
|
||||
// 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)
|
||||
slog.Error("combat: consume remove inventory item failed", "user", uid, "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, ""
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ type CombatModifiers struct {
|
||||
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
|
||||
@@ -157,9 +162,11 @@ type CombatModifiers struct {
|
||||
// ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the
|
||||
// start of each combat by Abjuration L5+ (2× Mage level, +prof at L7).
|
||||
// Persists across rounds within a single combat; not refunded between fights.
|
||||
// GrimHarvestSlot/Necrotic: snapshot of the queued spell stashed by
|
||||
// applyPendingCast for the post-combat Grim Harvest hook (Necromancy L5+).
|
||||
// Heal fires only if the spell event is what dropped the enemy to 0.
|
||||
// GrimHarvestSlot/Necrotic: snapshot of the damaging spell stashed for the
|
||||
// post-combat Grim Harvest hook (Necromancy L5+) — by applyPendingCast on
|
||||
// the auto-resolve path, and by seatFightStartMods reading the seat's
|
||||
// statuses on the turn-based one. Heal fires only if that spell's event is
|
||||
// what dropped the enemy to 0.
|
||||
ArcaneWardHP int
|
||||
GrimHarvestSlot int
|
||||
GrimHarvestNecrotic bool
|
||||
@@ -206,6 +213,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 +303,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 +333,10 @@ type combatState struct {
|
||||
reflectFrac float64
|
||||
autoCrit bool
|
||||
|
||||
// Monster ability effects
|
||||
// Monster ability effects landing on this actor
|
||||
poisonTicks int
|
||||
poisonDmg int
|
||||
stunPlayer bool
|
||||
enraged bool
|
||||
armorBroken bool
|
||||
armorBreakAmt float64
|
||||
|
||||
// Sovereign reprieve
|
||||
deathSaveUsed bool
|
||||
@@ -315,14 +346,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 +354,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 +428,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 +484,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 +1300,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}
|
||||
|
||||
787
internal/plugin/combat_engine_party.go
Normal file
787
internal/plugin/combat_engine_party.go
Normal file
@@ -0,0 +1,787 @@
|
||||
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 {
|
||||
// Party-only: bump the enemy's max HP so the fight lasts long enough for the
|
||||
// scaled action economy to actually threaten each member. Solo is unchanged
|
||||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||||
if len(players) > 1 {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// enemyActionsThisRound is how many attack-actions the enemy takes this round
|
||||
// against the seated roster. Solo returns 1 without drawing from the RNG, so both
|
||||
// combat engines collapse to their pre-party behaviour and the characterization
|
||||
// golden and d8prereq corpus do not move.
|
||||
//
|
||||
// A party's action budget is a *fractional expectation* (partyActionExpectation),
|
||||
// realised as floor(exp) actions plus one more with probability frac(exp). The
|
||||
// fraction matters because the action lever is coarse: against a party of two, an
|
||||
// integer 2 actions is a 100% faceroll and 3 is brutal (~45%), with nothing
|
||||
// between — a per-round coin-flip for the extra action is the only way to land
|
||||
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
||||
// stream is untouched.
|
||||
func enemyActionsThisRound(st *combatState) int {
|
||||
n := len(st.actors)
|
||||
if n < 2 {
|
||||
return 1
|
||||
}
|
||||
exp := partyActionExpectation(n)
|
||||
base := int(exp)
|
||||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||||
base++
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// partyActionExpectation is the expected number of enemy attack-actions per round
|
||||
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
||||
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
||||
// landed the martial-leader target only near ~2N actions, and HP alone can't touch
|
||||
// a martial (fighter stays at 100% even at enemy HP ×1.6). So the enemy's action
|
||||
// economy is the load-bearing lever, and it is fractional so a two-person party
|
||||
// lands between soloing and a trio instead of snapping to one of two integers
|
||||
// (against a duo, an integer 2 actions is a ~91% faceroll and 3 is a ~45% meat
|
||||
// grinder — 2.4 sits fighter+cleric at ~75%, between solo 70% and trio 90%).
|
||||
//
|
||||
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
||||
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
||||
// composition below its solo clear rate — bringing a friend is never a penalty.
|
||||
func partyActionExpectation(n int) float64 {
|
||||
switch {
|
||||
case n < 2:
|
||||
return 1
|
||||
case n == 2:
|
||||
return 2.4
|
||||
default:
|
||||
return float64(2*n - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
||||
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
||||
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
||||
// fixed, each member now takes ~1 swing a round, so a longer fight is more
|
||||
// cumulative exposure per member: HP became a live difficulty lever exactly the
|
||||
// way P6e predicted it would once the enemy stopped swinging only once.
|
||||
//
|
||||
// 1.15 is where the P8 sim sweep landed the band: with 2N−1 actions, a party of
|
||||
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
||||
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||||
// not the 100% faceroll a single enemy swing produced.
|
||||
func partyEnemyHPScale(rosterSize int) float64 {
|
||||
if rosterSize < 2 {
|
||||
return 1.0
|
||||
}
|
||||
return 1.15
|
||||
}
|
||||
|
||||
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
||||
// rule, so every call site (the auto-resolve engine, the party session's initial
|
||||
// persist, and the per-turn rebuild) agrees on the same number.
|
||||
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
|
||||
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
|
||||
}
|
||||
|
||||
// enemyActionPlan is the shared action budget both combat engines resolve an
|
||||
// enemy turn against: how many attack-actions the enemy takes, and whether the
|
||||
// first one reuses the round's already-picked target (and its already-rolled
|
||||
// procs). A cleave/lifesteal ability already spent the first action, so one fewer
|
||||
// follows and none of them reuse the initial target. Both engines call this so
|
||||
// the load-bearing count stays in lockstep even though their per-action bodies
|
||||
// differ.
|
||||
func enemyActionPlan(st *combatState, abilityDealtDamage bool) (count int, reuseFirst bool) {
|
||||
count = enemyActionsThisRound(st)
|
||||
reuseFirst = !abilityDealtDamage
|
||||
if abilityDealtDamage {
|
||||
count--
|
||||
}
|
||||
return count, reuseFirst
|
||||
}
|
||||
|
||||
// enemyRoundSwings resolves the enemy's attacks for one round of the auto-resolve
|
||||
// engine. A solo roster takes exactly one swing at its single seat, reusing the
|
||||
// round's already-rolled target and pet procs, so its RNG stream and event log are
|
||||
// byte-for-byte the pre-P8 engine's. A party faces enemyActionsThisRound() swings,
|
||||
// each re-targeted at a random standing seat with that seat's own pet procs, so the
|
||||
// damage is spread across the roster instead of pinning the round's first target.
|
||||
//
|
||||
// abilityDealtDamage means a cleave/lifesteal already spent the enemy's first
|
||||
// action this round, so one fewer swing follows — for solo that collapses to the
|
||||
// old "the ability stands in for the attack" skip. Returns true if the fight is
|
||||
// decided.
|
||||
func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult,
|
||||
target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool {
|
||||
|
||||
swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage)
|
||||
for k := 0; k < swings; k++ {
|
||||
tgt := target
|
||||
sw, sd, sp := petWhiff, petDeflect, sporeMiss
|
||||
if !(reuseFirst && k == 0) {
|
||||
// A fresh target for every swing past the first: re-pick among the
|
||||
// standing seats and roll that seat's own pet procs. This branch never
|
||||
// runs for a solo roster, so it adds no draws to the solo stream.
|
||||
var alive bool
|
||||
if tgt, alive = enemyTargetSeat(st); !alive {
|
||||
return combatOver(st)
|
||||
}
|
||||
st.seat(tgt)
|
||||
sw = st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
|
||||
sd = st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
|
||||
if sd {
|
||||
seats[tgt].PetDeflected = true
|
||||
}
|
||||
sp = st.sporeRounds > 0 && st.randFloat() < 0.15
|
||||
}
|
||||
st.seat(tgt)
|
||||
mark := len(st.events)
|
||||
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[tgt], sw, sd, sp)
|
||||
stampEventSeats(st, mark, tgt)
|
||||
if over && combatOver(st) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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 enemyRoundSwings(st, enemy, phase, seats, target, abilityDealtDamage, petWhiff, petDeflect, sporeMiss) {
|
||||
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
|
||||
}
|
||||
|
||||
// ── Misty's pair, shared by both engines ─────────────────────────────────────
|
||||
//
|
||||
// These two are the only round-end effects the turn engine did not have its own
|
||||
// copy of. Everything else in endOfRoundForSeat either exists there already (the
|
||||
// pet, the spiritual weapon — fired after a player action rather than at round
|
||||
// end), is deliberately absent (the environmental hazard: turnCombatPhase is a
|
||||
// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an
|
||||
// explicit player command (the consumable auto-heal, which is `!consume`).
|
||||
//
|
||||
// So they are hoisted rather than re-implemented. A parallel sibling is what let
|
||||
// the two win close-outs drift apart (deferred item D); this pair is now one
|
||||
// list of effects with two callers, and cannot.
|
||||
//
|
||||
// Neither draws from the RNG unless its proc is armed, so a character with no
|
||||
// Misty history rolls exactly the dice it rolled before — the sim corpus and
|
||||
// combat_characterization.golden do not move.
|
||||
|
||||
// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing
|
||||
// at the end of the round. Returns true when it decided the fight — that is,
|
||||
// when it dropped this character, the death save failed, and nobody else is
|
||||
// standing. A downed character in a still-live party returns false.
|
||||
func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool {
|
||||
if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc {
|
||||
return false
|
||||
}
|
||||
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",
|
||||
})
|
||||
return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive()
|
||||
}
|
||||
|
||||
// mistyHeal is the buff's payout. It cannot decide the fight, so it returns
|
||||
// nothing. result may be a scratch value the caller discards (the turn engine's
|
||||
// is) — the durable record is the misty_heal event on the log, which is what
|
||||
// seatCombatResult reads to award combat_misty_clutch.
|
||||
func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) {
|
||||
if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc {
|
||||
return
|
||||
}
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// seatEndOfRound runs the round-end effects the turn engine owes one seat, in
|
||||
// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat
|
||||
// survived it — Misty's heal. Returns true when the fight is over.
|
||||
//
|
||||
// It exists because the turn engine's stepRoundEnd never ran either one. A
|
||||
// player carrying Misty's buff lost it by fighting manually; worse, a player
|
||||
// carrying her debuff *escaped* it by doing the same, which needed no discovery
|
||||
// to exploit — just press !attack.
|
||||
func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool {
|
||||
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
||||
return true
|
||||
}
|
||||
if st.playerHP <= 0 {
|
||||
return false
|
||||
}
|
||||
mistyHeal(st, player, phaseName, result)
|
||||
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 over := mistyCrowdRevenge(st, player, phaseName); over {
|
||||
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
|
||||
mistyHeal(st, player, phaseName, result)
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
275
internal/plugin/combat_engine_party_test.go
Normal file
275
internal/plugin/combat_engine_party_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// P8 scaling constants: solo is exempt on both levers (1 action, ×1.0 HP) so the
|
||||
// characterization golden and the d8prereq corpus are untouched. A party's action
|
||||
// budget is a fractional expectation — 2.4 for a duo (so it lands between soloing
|
||||
// and a trio, where an integer 2 vs 3 has no room), 2N−1 for N≥3 — plus ×1.15
|
||||
// enemy HP.
|
||||
func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
|
||||
if got := partyActionExpectation(1); got != 1 {
|
||||
t.Fatalf("solo action expectation = %v, want 1", got)
|
||||
}
|
||||
if got := partyEnemyHPScale(1); got != 1.0 {
|
||||
t.Fatalf("solo HP scale = %v, want 1.0", got)
|
||||
}
|
||||
if got := scaledEnemyMaxHP(200, 1); got != 200 {
|
||||
t.Fatalf("solo enemy HP = %d, want 200 (unscaled)", got)
|
||||
}
|
||||
if got := partyActionExpectation(2); got != 2.4 {
|
||||
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
||||
}
|
||||
for n := 3; n <= 5; n++ {
|
||||
if got, want := partyActionExpectation(n), float64(2*n-1); got != want {
|
||||
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
|
||||
}
|
||||
}
|
||||
if got := partyEnemyHPScale(3); got != 1.15 {
|
||||
t.Fatalf("party HP scale = %v, want 1.15", got)
|
||||
}
|
||||
// int(200*1.15) truncates 229.99… to 229; the 1-HP floor is immaterial.
|
||||
if got := scaledEnemyMaxHP(200, 3); got != 229 {
|
||||
t.Fatalf("party enemy HP = %d, want 229 (trunc 200*1.15)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// P8: the enemy's action economy scales with the roster. A party of N faces up to
|
||||
// N attack-actions a round, each re-targeted, so the enemy's damage spreads across
|
||||
// the roster instead of pinning one seat — the 1/N² exposure that made P6e's party
|
||||
// a 100%-clear faceroll. Solo stays at exactly one swing a round, the pre-party
|
||||
// behaviour the characterization golden pins.
|
||||
func TestSimulateParty_EnemyActionEconomyScalesWithRoster(t *testing.T) {
|
||||
enemyRollsPerRound := func(res PartyCombatResult) map[int]int {
|
||||
perRound := map[int]int{}
|
||||
for _, e := range res.Events {
|
||||
if e.Actor == "enemy" && e.Roll > 0 {
|
||||
perRound[e.Round]++
|
||||
}
|
||||
}
|
||||
return perRound
|
||||
}
|
||||
|
||||
// Solo: never more than one enemy swing in a round.
|
||||
soloTank := baseEnemy()
|
||||
soloTank.Stats.MaxHP = 5000
|
||||
solo := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer()}, soloTank, dungeonCombatPhases, seededRNG(23))
|
||||
if len(enemyRollsPerRound(solo)) == 0 {
|
||||
t.Fatal("solo: the enemy never attacked")
|
||||
}
|
||||
for round, n := range enemyRollsPerRound(solo) {
|
||||
if n > 1 {
|
||||
t.Fatalf("solo round %d: enemy swung %d times, want at most 1", round, n)
|
||||
}
|
||||
}
|
||||
|
||||
// Party of 3: no round exceeds the roster's action budget, and at least one
|
||||
// round sees more than one swing — the enemy is really taking extra actions.
|
||||
tank := baseEnemy()
|
||||
tank.Stats.MaxHP = 5000
|
||||
party := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23))
|
||||
budget := int(partyActionExpectation(3))
|
||||
if partyActionExpectation(3) > float64(budget) {
|
||||
budget++ // ceil: the coin-flip round can add one action
|
||||
}
|
||||
sawMulti := false
|
||||
for round, n := range enemyRollsPerRound(party) {
|
||||
if n > budget {
|
||||
t.Fatalf("party round %d: enemy swung %d times, over the %d-action budget", round, n, budget)
|
||||
}
|
||||
if n > 1 {
|
||||
sawMulti = true
|
||||
}
|
||||
}
|
||||
if !sawMulti {
|
||||
t.Fatal("a party of 3 never faced more than one enemy swing in a round — action economy did not scale")
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
232
internal/plugin/combat_grim_harvest_turn_test.go
Normal file
232
internal/plugin/combat_grim_harvest_turn_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Grim Harvest on the turn-based surface.
|
||||
//
|
||||
// The auto-resolve path stashes the killing spell's slot level on the
|
||||
// fight-start CombatModifiers, where the close-out reads it. A turn-based fight
|
||||
// has nowhere to keep that: it rebuilds its combatants from the session row on
|
||||
// every !attack / !cast. resolveTurnSpell computed the stash into a local mod
|
||||
// set and dropped it on the floor, so a Necromancy Mage who killed with a spell
|
||||
// never healed — on a class that already trails.
|
||||
//
|
||||
// The stash now rides on the casting seat's ActorStatuses, and the close-out
|
||||
// asks whether the *last* spell_cast is the one that dropped the enemy to 0.
|
||||
//
|
||||
// (Empowered Evocation and Overchannel were never broken here: they only move
|
||||
// mods.SpellPreDamage, which resolveTurnSpell already returns as EnemyDamage.)
|
||||
|
||||
// necromancer is a Mage who has reached L5 Grim Harvest.
|
||||
func necromancer(t *testing.T, uid id.UserID, level int) *DnDCharacter {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, string(uid)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassMage, Subclass: SubclassNecromancy, Level: level,
|
||||
STR: 8, DEX: 12, CON: 12, INT: 16, WIS: 10, CHA: 10,
|
||||
HPMax: 30, HPCurrent: 10, ArmorClass: 12,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// tough is a stat block a spell can be cast at without any of it mattering.
|
||||
func tough() CombatStats { return CombatStats{MaxHP: 40, AC: 10} }
|
||||
|
||||
// ── the leak itself ──────────────────────────────────────────────────────────
|
||||
|
||||
// resolveTurnSpell must hand the stash back to its caller. Before this, the
|
||||
// slot level lived and died inside the function.
|
||||
func TestResolveTurnSpell_CarriesTheGrimHarvestStashOut(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
|
||||
// magic_missile: auto-hit, force damage, upcast to a 3rd-level slot.
|
||||
spell, _ := lookupSpell("magic_missile")
|
||||
enemy := tough()
|
||||
out, ok := resolveTurnSpell(c, spell, 3, &enemy)
|
||||
if !ok {
|
||||
t.Fatal("magic_missile should be a supported turn spell")
|
||||
}
|
||||
if out.GrimHarvestSlot != 3 {
|
||||
t.Errorf("GrimHarvestSlot = %d, want 3 (the slot it was cast at)", out.GrimHarvestSlot)
|
||||
}
|
||||
if out.GrimHarvestNecrotic {
|
||||
t.Error("magic_missile deals force damage — Necrotic should be false")
|
||||
}
|
||||
|
||||
// blight: auto-hit, necrotic — the flag that triples the heal.
|
||||
spell, _ = lookupSpell("blight")
|
||||
enemy = tough()
|
||||
out, _ = resolveTurnSpell(c, spell, 4, &enemy)
|
||||
if out.GrimHarvestSlot != 4 || !out.GrimHarvestNecrotic {
|
||||
t.Errorf("blight L4: slot=%d necrotic=%v, want 4/true", out.GrimHarvestSlot, out.GrimHarvestNecrotic)
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody else stashes. A Mage below L5 has not learned the harvest, and an
|
||||
// Evocation Mage never will.
|
||||
func TestResolveTurnSpell_NoStashForAnyoneElse(t *testing.T) {
|
||||
spell, _ := lookupSpell("magic_missile")
|
||||
for _, c := range []*DnDCharacter{
|
||||
{Class: ClassMage, Subclass: SubclassNecromancy, Level: 4, INT: 16},
|
||||
{Class: ClassMage, Subclass: SubclassEvocation, Level: 12, INT: 16},
|
||||
{Class: ClassSorcerer, Level: 12, CHA: 16},
|
||||
} {
|
||||
enemy := tough()
|
||||
out, _ := resolveTurnSpell(c, spell, 3, &enemy)
|
||||
if out.GrimHarvestSlot != 0 {
|
||||
t.Errorf("%s/%s L%d stashed slot %d, want 0",
|
||||
c.Class, c.Subclass, c.Level, out.GrimHarvestSlot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── which cast counts ────────────────────────────────────────────────────────
|
||||
|
||||
// The killing-blow check used to break on the first spell_cast it saw. In a
|
||||
// turn-based fight the mage casts every round, so the first one is an opening
|
||||
// cantrip that left the enemy standing — and it vetoed the heal the killing
|
||||
// spell had earned. It is the last cast that has to be lethal.
|
||||
func TestGrimHarvestHeal_ReadsTheLastCastNotTheFirst(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
|
||||
|
||||
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 22}, // opening cantrip, enemy stands
|
||||
{Round: 2, Action: "enemy_attack", EnemyHP: 22},
|
||||
{Round: 2, Action: "spell_cast", EnemyHP: 0}, // this one killed it
|
||||
}}
|
||||
if got := grimHarvestHeal(c, result, mods); got != 6 {
|
||||
t.Errorf("heal = %d, want 6 (3× a 2nd-level necrotic slot)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror: the mage softened it up with a spell, then finished it with a
|
||||
// weapon. No spell landed the blow, so no harvest.
|
||||
func TestGrimHarvestHeal_WeaponKillAfterASpellDoesNotHarvest(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
|
||||
|
||||
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 9},
|
||||
{Round: 2, Action: "player_attack", EnemyHP: 0},
|
||||
}}
|
||||
if got := grimHarvestHeal(c, result, mods); got != 0 {
|
||||
t.Errorf("heal = %d, want 0 — the sword landed the blow, not the spell", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the seam through the session ─────────────────────────────────────────────
|
||||
|
||||
// The stash has to survive the round-trip the fight puts it through: parked on
|
||||
// the seat by the cast, carried across commit()'s snapshot, read back by the
|
||||
// close-out.
|
||||
func TestSeatFightStartMods_ReadsTheStashOffTheSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@seatstash:example.org")
|
||||
c := necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{UserID: string(uid), Status: CombatStatusWon}
|
||||
sess.Statuses.GrimHarvestSlot = 3
|
||||
sess.Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
mods := seatFightStartMods(sess, 0, c)
|
||||
if mods.GrimHarvestSlot != 3 || !mods.GrimHarvestNecrotic {
|
||||
t.Fatalf("seatFightStartMods lost the stash: %+v", mods)
|
||||
}
|
||||
|
||||
// snapshotActor rebuilds ActorStatuses from combatState each commit; fields
|
||||
// with no combatState counterpart must be carried over from the prior
|
||||
// snapshot, the way ArmedAbility is.
|
||||
kept := snapshotActor(&actor{}, sess.Statuses.ActorStatuses)
|
||||
if kept.GrimHarvestSlot != 3 || !kept.GrimHarvestNecrotic {
|
||||
t.Errorf("commit() dropped the stash: %+v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end at the close-out: a necromancer who ended the fight at 10/30 HP
|
||||
// with a lethal 2nd-level necrotic spell walks away with 6 HP back.
|
||||
func TestPostCombatBookkeepingForSeat_GrimHarvestHealsOnASpellKill(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@harvest:example.org")
|
||||
necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 3,
|
||||
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
|
||||
TurnLog: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 14},
|
||||
{Round: 3, Action: "spell_cast", EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
sess.Statuses.GrimHarvestSlot = 2
|
||||
sess.Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.HPCurrent != 16 {
|
||||
t.Errorf("HPCurrent = %d after a turn-based spell kill, want 16 (10 + 3×2)", got.HPCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat with no stash — the mage never cast, or missed every time — is left
|
||||
// exactly as the fight left them.
|
||||
func TestPostCombatBookkeepingForSeat_NoStashNoHeal(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@nostash:example.org")
|
||||
necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 2,
|
||||
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
|
||||
TurnLog: []CombatEvent{{Round: 2, Action: "player_attack", EnemyHP: 0}},
|
||||
}
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.HPCurrent != 10 {
|
||||
t.Errorf("HPCurrent = %d, want 10 — nothing was stashed", got.HPCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
// Party fights: the stash is per-seat, so seat 1's harvest cannot heal seat 0.
|
||||
// This is the same class of bug P5 fixed for mid-fight buffs.
|
||||
func TestGrimHarvestStash_IsPerSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader, member := id.UserID("@leader:example.org"), id.UserID("@member:example.org")
|
||||
fightTestChar(t, leader, 30)
|
||||
necromancer(t, member, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(leader), Status: CombatStatusWon, Round: 2,
|
||||
PlayerHP: 20, PlayerHPMax: 30, EnemyHP: 0,
|
||||
Participants: []CombatParticipant{{Seat: 1, UserID: string(member), HP: 10, HPMax: 30}},
|
||||
TurnLog: []CombatEvent{
|
||||
{Round: 2, Seat: 1, Action: "spell_cast", EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
sess.Participants[0].Statuses.GrimHarvestSlot = 2
|
||||
sess.Participants[0].Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.postCombatBookkeepingForSeat(sess, 0)
|
||||
p.postCombatBookkeepingForSeat(sess, 1)
|
||||
|
||||
gotLeader, _ := LoadDnDCharacter(leader)
|
||||
if gotLeader.HPCurrent != 30 {
|
||||
t.Errorf("leader HPCurrent = %d, want 30 — the member's harvest is not theirs", gotLeader.HPCurrent)
|
||||
}
|
||||
gotMember, _ := LoadDnDCharacter(member)
|
||||
if gotMember.HPCurrent != 16 {
|
||||
t.Errorf("member HPCurrent = %d, want 16 (10 + 3×2)", gotMember.HPCurrent)
|
||||
}
|
||||
}
|
||||
169
internal/plugin/combat_misty_turn_test.go
Normal file
169
internal/plugin/combat_misty_turn_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Misty's two round-end procs on the turn-based surface.
|
||||
//
|
||||
// Both were built onto every turn-based combatant by DerivePlayerStats and then
|
||||
// never read: stepRoundEnd had no counterpart to endOfRoundForSeat. The buff was
|
||||
// simply lost. The debuff was an exploit — a player who declined Misty escaped
|
||||
// her crowd entirely by fighting with !attack instead of letting the room
|
||||
// auto-resolve, which needed no discovery at all.
|
||||
//
|
||||
// seatEndOfRound is the shared hook. These pin that it fires, that it cannot be
|
||||
// dodged, and that a character with no Misty history is not touched at all —
|
||||
// the property that keeps the sim corpus and the golden file still.
|
||||
|
||||
// mistyState seats one combatant at hp, cursor armed, with a seeded RNG.
|
||||
func mistyState(t *testing.T, player *Combatant, hp int) *combatState {
|
||||
t.Helper()
|
||||
st := testCombatState(hp, 100, 1, rand.New(rand.NewPCG(7, 7)))
|
||||
st.actor.c = player
|
||||
st.actor.hpMax = player.Stats.MaxHP
|
||||
return st
|
||||
}
|
||||
|
||||
// The two procs at certainty, so no test depends on a roll.
|
||||
func mistyCursed(maxHP, dmg int) *Combatant {
|
||||
return &Combatant{
|
||||
Name: "Cursed",
|
||||
Stats: CombatStats{MaxHP: maxHP, AC: 10},
|
||||
Mods: CombatModifiers{CrowdRevengeProc: 1.0, CrowdRevengeDmg: dmg},
|
||||
}
|
||||
}
|
||||
|
||||
func mistyBuffed(maxHP, heal int) *Combatant {
|
||||
return &Combatant{
|
||||
Name: "Buffed",
|
||||
Stats: CombatStats{MaxHP: maxHP, AC: 10},
|
||||
Mods: CombatModifiers{MistyHealProc: 1.0, MistyHealAmt: heal},
|
||||
}
|
||||
}
|
||||
|
||||
// ── the exploit ──────────────────────────────────────────────────────────────
|
||||
|
||||
// The debuff must land at round end in a manual fight, exactly as it does when
|
||||
// the room auto-resolves. Before seatEndOfRound, !attack was a clean escape.
|
||||
func TestSeatEndOfRound_CrowdRevengeCannotBeDodgedByFightingManually(t *testing.T) {
|
||||
st := mistyState(t, mistyCursed(40, 6), 40)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if over {
|
||||
t.Fatal("a 6-damage swing against 40 HP should not end the fight")
|
||||
}
|
||||
if st.playerHP != 34 {
|
||||
t.Errorf("playerHP = %d, want 34 — Misty's crowd took its swing", st.playerHP)
|
||||
}
|
||||
if !hasAction(st.events, "crowd_revenge") {
|
||||
t.Error("no crowd_revenge event on the log")
|
||||
}
|
||||
}
|
||||
|
||||
// The debuff can be lethal, and a lethal one with nobody else standing ends the
|
||||
// fight rather than leaving a corpse to take the next round's turn.
|
||||
func TestSeatEndOfRound_CrowdRevengeCanEndASoloFight(t *testing.T) {
|
||||
st := mistyState(t, mistyCursed(40, 30), 3)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if st.playerHP != 0 {
|
||||
t.Fatalf("playerHP = %d, want 0", st.playerHP)
|
||||
}
|
||||
if !over {
|
||||
t.Error("a solo character dropped by the crowd should end the fight")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the buff ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// The heal fires, caps at max HP, and marks the result so the achievement can
|
||||
// be granted.
|
||||
func TestSeatEndOfRound_MistyHealFiresAndCaps(t *testing.T) {
|
||||
st := mistyState(t, mistyBuffed(40, 12), 34)
|
||||
var res CombatResult
|
||||
seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &res)
|
||||
|
||||
if st.playerHP != 40 {
|
||||
t.Errorf("playerHP = %d, want 40 — the heal caps at max HP", st.playerHP)
|
||||
}
|
||||
if !res.MistyHealed {
|
||||
t.Error("MistyHealed flag not set")
|
||||
}
|
||||
if !hasAction(st.events, "misty_heal") {
|
||||
t.Error("no misty_heal event on the log")
|
||||
}
|
||||
}
|
||||
|
||||
// A character the crowd just dropped is not then healed back up by the same
|
||||
// hook. The heal is for the living.
|
||||
func TestSeatEndOfRound_NoHealForACharacterTheCrowdJustDropped(t *testing.T) {
|
||||
c := mistyCursed(40, 40)
|
||||
c.Mods.MistyHealProc = 1.0
|
||||
c.Mods.MistyHealAmt = 20
|
||||
|
||||
st := mistyState(t, c, 10)
|
||||
st.actors = append(st.actors, &actor{playerHP: 5}) // an ally keeps the fight alive
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if over {
|
||||
t.Fatal("an ally is still standing — the fight is not over")
|
||||
}
|
||||
if st.playerHP != 0 {
|
||||
t.Errorf("playerHP = %d, want 0 — the heal must not resurrect them", st.playerHP)
|
||||
}
|
||||
if hasAction(st.events, "misty_heal") {
|
||||
t.Error("misty_heal fired on a downed character")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the property that protects the corpus ────────────────────────────────────
|
||||
|
||||
// A character with no Misty history is untouched: no HP change, no events, and
|
||||
// — because both procs short-circuit before st.randFloat() — no dice drawn. If
|
||||
// the hook drew even one float, every simulated fight would diverge and
|
||||
// combat_characterization.golden would move.
|
||||
func TestSeatEndOfRound_UnbuffedCharacterIsUntouchedAndDrawsNoDice(t *testing.T) {
|
||||
plain := &Combatant{Name: "Plain", Stats: CombatStats{MaxHP: 40, AC: 10}}
|
||||
|
||||
st := mistyState(t, plain, 40)
|
||||
control := mistyState(t, plain, 40) // same seed, never passed to the hook
|
||||
|
||||
if over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}); over {
|
||||
t.Fatal("an unbuffed character cannot be dropped by a hook that does nothing")
|
||||
}
|
||||
if st.playerHP != 40 || len(st.events) != 0 {
|
||||
t.Errorf("unbuffed character was touched: hp=%d events=%d", st.playerHP, len(st.events))
|
||||
}
|
||||
// The RNG streams must still be in lockstep: the hook consumed nothing.
|
||||
if got, want := st.randFloat(), control.randFloat(); got != want {
|
||||
t.Errorf("seatEndOfRound consumed RNG: next float %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the wiring ───────────────────────────────────────────────────────────────
|
||||
|
||||
// The unit tests above call seatEndOfRound directly, which proves the hook is
|
||||
// correct but not that stepRoundEnd calls it. This drives the real session API
|
||||
// through a round_end step. Comment the call out of stepRoundEnd and this test
|
||||
// reports PlayerHP=40 — the exploit, exactly as it shipped.
|
||||
func TestStepRoundEnd_WiresSeatEndOfRound(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
player := mistyCursed(40, 6)
|
||||
enemy := &Combatant{Name: "Target", Stats: CombatStats{MaxHP: 100, AC: 10}}
|
||||
sess := &CombatSession{
|
||||
SessionID: "wiring", UserID: "@u:example.org", Status: CombatStatusActive,
|
||||
PlayerHP: 40, PlayerHPMax: 40, EnemyHP: 100,
|
||||
Phase: CombatPhaseRoundEnd, Round: 1,
|
||||
}
|
||||
if _, err := advanceCombatSession(sess, player, enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.PlayerHP != 34 {
|
||||
t.Errorf("PlayerHP = %d, want 34 — stepRoundEnd never ran the hook", sess.PlayerHP)
|
||||
}
|
||||
if !hasAction(sess.TurnLog, "crowd_revenge") {
|
||||
t.Error("no crowd_revenge event persisted to the session log")
|
||||
}
|
||||
}
|
||||
@@ -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,86 @@ 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 "crowd_revenge":
|
||||
// Deliberately unattributed. An ally sees the damage land — silence
|
||||
// would read as HP vanishing — but Misty's grudge is the owner's own
|
||||
// discovery, and naming her here would spoil it for the whole party.
|
||||
// Same reason misty_heal below reads as a plain recovery.
|
||||
return down(fmt.Sprintf("%s takes %d.", 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
|
||||
}
|
||||
225
internal/plugin/combat_party_finish.go
Normal file
225
internal/plugin/combat_party_finish.go
Normal file
@@ -0,0 +1,225 @@
|
||||
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, and
|
||||
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||
for seat := range sess.RosterSize() {
|
||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||
p.postCombatBookkeepingForSeat(sess, 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:
|
||||
endRunOnLoss(owner, sess.RunID, false)
|
||||
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)
|
||||
|
||||
bossOnExpedition := p.applyOwnerWinEffects(owner, sess.EnemyID, elite)
|
||||
|
||||
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.
|
||||
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
||||
|
||||
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)
|
||||
|
||||
endRunOnLoss(owner, sess.RunID, true)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ── Shared terminal effects (item D) ─────────────────────────────────────────
|
||||
//
|
||||
// finishCombatSession (solo, combat_cmd.go) and finishPartyWin/finishPartyLoss
|
||||
// (party, above) still write their own player-facing text — the solo line says
|
||||
// "You finished", the party line says "The party stands", and the death-on-win
|
||||
// rule is deliberately roster-size-dependent (see item E). But the *effects*
|
||||
// they fire were hand-copied lists, and item A drifted exactly there. These
|
||||
// helpers are the single copy of each effect; both close-outs route through
|
||||
// them so the effect lists cannot diverge again.
|
||||
|
||||
// applyOwnerWinEffects fires the terminal effects a win owes the run and
|
||||
// expedition — the zone-kill record, room threat, and the boss-defeat threat
|
||||
// drop — once, through the row's owner (a party member owns neither row).
|
||||
// Returns whether the kill was a zone boss on an active expedition; both
|
||||
// close-outs use it to reframe the final line as the expedition's climax.
|
||||
func (p *AdventurePlugin) applyOwnerWinEffects(owner id.UserID, enemyID string, elite bool) (bossOnExpedition bool) {
|
||||
recordZoneKillForUser(owner, enemyID)
|
||||
applyRoomCombatThreatForUser(owner, elite)
|
||||
if !elite {
|
||||
// §8.1 — a zone boss defeat drops expedition threat. Silent no-op for a
|
||||
// standalone zone run with no active expedition.
|
||||
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
return bossOnExpedition
|
||||
}
|
||||
|
||||
// grantSeatWinXP pays one combatant their kill XP. A seat that finished at a
|
||||
// fifth of its pool or less takes the near-death path. Full XP per person,
|
||||
// solo or party, per the accessibility-over-crunch stance.
|
||||
func (p *AdventurePlugin) grantSeatWinXP(uid id.UserID, hp, hpMax int, monster DnDMonsterTemplate, tier int) {
|
||||
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: grantDnDXP", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endRunOnLoss tears down the owner's run and wrapping expedition when a fight
|
||||
// ends in death or flight. On a death (not a flee) it also stamps the run's
|
||||
// mood event. Owner-scoped, fires once; the per-seat death mark stays with the
|
||||
// caller, since it is decided per HP and gated on roster size.
|
||||
func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
||||
if death {
|
||||
if run, _ := getZoneRun(runID); run != nil {
|
||||
_, _ = applyMoodEvent(runID, MoodEventPlayerDeath)
|
||||
}
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
reason := "combat flee"
|
||||
if death {
|
||||
reason = "combat death"
|
||||
}
|
||||
forceExtractExpeditionForRunLoss(owner, reason)
|
||||
}
|
||||
212
internal/plugin/combat_party_start.go
Normal file
212
internal/plugin/combat_party_start.go
Normal file
@@ -0,0 +1,212 @@
|
||||
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
|
||||
|
||||
// Both refusals below are cheap and neither needs the build, so they run
|
||||
// before it: consuming a seat's armed ability and *then* sitting them out
|
||||
// would spend their rage on a fight they never joined.
|
||||
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
|
||||
}
|
||||
|
||||
// Consumed exactly once for the fight, here. Every later rebuild
|
||||
// re-applies this id off the seat's statuses rather than re-arming.
|
||||
armed := ""
|
||||
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
|
||||
trySimAutoArm(c)
|
||||
armed = consumeArmedAbility(c)
|
||||
}
|
||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
||||
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
|
||||
}
|
||||
if armed != "" {
|
||||
slog.Info("combat: armed ability fired (turn-based start)", "user", uid, "ability", armed)
|
||||
}
|
||||
|
||||
seats = append(seats, CombatSeatSetup{
|
||||
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
||||
})
|
||||
}
|
||||
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)
|
||||
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.
|
||||
slog.Warn("combat: reaper cannot rebuild session, marking expired",
|
||||
"user", userID, "session", sess.SessionID, "err", err)
|
||||
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
|
||||
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
|
||||
}
|
||||
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 {
|
||||
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.
|
||||
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"`
|
||||
Enraged bool `json:"enraged,omitempty"`
|
||||
ArmorBroken bool `json:"armor_broken,omitempty"`
|
||||
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
|
||||
// EnemySkipNext carries a control-spell skip (Hold Person, Sleep) from the
|
||||
// player_turn phase it was cast in to the enemy_turn phase that consumes it
|
||||
// — those phases resolve as separate engine steps with separate in-memory
|
||||
// combatState, so the flag must survive the commit between them.
|
||||
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
||||
|
||||
// PetProcReady is the per-fight pet-attack outcome. Auto-resolve rolls the
|
||||
// pet proc every round; a manual fight can run many rounds, so the roll is
|
||||
// decided once at fight start (rollCombatSessionPetProc) and parked here.
|
||||
// The pet then lands a single hit on the player's first acting turn, which
|
||||
// clears the flag — persisted so a suspend/resume or reaper auto-play sees
|
||||
// the same outcome.
|
||||
PetProcReady bool `json:"pet_proc_ready,omitempty"`
|
||||
|
||||
// Fight-scoped depleting resources — mirror the combatState charges that
|
||||
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
|
||||
// a mid-fight !cast / !consume, restored into combatState on every resume,
|
||||
// written back on every commit so a depleting charge can't silently reset
|
||||
// when a fight is suspended and resumed.
|
||||
// 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,33 @@ 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"`
|
||||
|
||||
// ArmedAbility is the id of the active ability this character armed and
|
||||
// spent entering the fight (rage, second_wind, …). The resource is already
|
||||
// debited and the character disarmed; this is the record that lets every
|
||||
// rebuild of an in-flight fight re-apply the ability's mods, and lets the
|
||||
// close-out know a rage fired. Empty when nothing was armed.
|
||||
ArmedAbility string `json:"armed_ability,omitempty"`
|
||||
|
||||
// GrimHarvestSlot / GrimHarvestNecrotic snapshot the most recent damaging
|
||||
// spell this seat cast, for the Necromancy Mage's post-combat kill-heal.
|
||||
// The auto-resolve path carries the same pair on CombatModifiers, stashed
|
||||
// once by applyPendingCast; the turn-based path can cast every round, so
|
||||
// the stash lives here and each damaging cast overwrites it. Whether the
|
||||
// heal actually fires is decided at close-out by grimHarvestHeal, which
|
||||
// asks whether the *last* spell_cast event is the one that dropped the
|
||||
// enemy to 0 — so a stale stash from an earlier, non-lethal cast is inert.
|
||||
GrimHarvestSlot int `json:"grim_harvest_slot,omitempty"`
|
||||
GrimHarvestNecrotic bool `json:"grim_harvest_necrotic,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 +117,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 +196,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 +203,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 +216,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
|
||||
@@ -163,16 +239,37 @@ func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
||||
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
||||
// but the full set is seeded for robustness. Returns true if anything was set.
|
||||
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
|
||||
st := &s.Statuses
|
||||
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
|
||||
}
|
||||
|
||||
// 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, seat CombatSeatSetup) bool {
|
||||
playerMods := seat.Mods
|
||||
st.WardCharges = playerMods.WardCharges
|
||||
st.SporeRounds = playerMods.SporeCloud
|
||||
st.ReflectFrac = playerMods.ReflectNext
|
||||
st.AutoCritFirst = playerMods.AutoCritFirst
|
||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||
st.HealChargesLeft = playerMods.HealItemCharges
|
||||
st.ArmedAbility = seat.ArmedAbility
|
||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
||||
st.ArmedAbility != ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -194,11 +291,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 +413,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 +474,87 @@ 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")
|
||||
}
|
||||
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
||||
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
||||
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
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)
|
||||
|
||||
if len(seats) > 1 {
|
||||
ps := make([]CombatParticipant, 0, len(seats)-1)
|
||||
for i, s := range seats[1:] {
|
||||
var st ActorStatuses
|
||||
seedActorOneShots(&st, s)
|
||||
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
|
||||
// ArmedAbility is the ability id this seat consumed entering the fight, ""
|
||||
// if they armed nothing. It is persisted onto the seat's statuses so every
|
||||
// later rebuild can re-apply the ability without re-spending it.
|
||||
ArmedAbility string
|
||||
}
|
||||
|
||||
// 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 +566,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 +597,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 +617,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 +633,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,17 +656,23 @@ 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 {
|
||||
@@ -384,6 +682,36 @@ func saveCombatSession(s *CombatSession) error {
|
||||
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
|
||||
@@ -407,9 +735,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'
|
||||
|
||||
@@ -33,11 +33,18 @@ import (
|
||||
// + armed ability) and the monster's bestiary stat block with tier scaling and
|
||||
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
|
||||
// callers can run post-combat subclass persistence without reloading it.
|
||||
//
|
||||
// armed is the ability id already consumed for this fight (consumeArmedAbility,
|
||||
// or armAbilityForFight's return on the auto-resolve paths); "" means none. The
|
||||
// build re-applies it rather than consuming, because the turn-based path calls
|
||||
// this once per player command and a consuming build would spend the ability on
|
||||
// round 1 and drop it for the rest of the fight.
|
||||
func (p *AdventurePlugin) buildZoneCombatants(
|
||||
userID id.UserID,
|
||||
monster DnDMonsterTemplate,
|
||||
tier int,
|
||||
dmMood int,
|
||||
armed string,
|
||||
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
||||
tilt := dmMoodCombatTilt(dmMood)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
@@ -64,10 +71,7 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
||||
}
|
||||
applyAbilityByID(dndChar, armed, &playerMods)
|
||||
|
||||
enemyStats, enemyMods := monster.toCombatStats()
|
||||
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
|
||||
@@ -107,41 +111,172 @@ 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)
|
||||
|
||||
seats := sess.SeatUserIDs()
|
||||
players := make([]*Combatant, len(seats))
|
||||
var enemy Combatant
|
||||
for seat, uid := range seats {
|
||||
// The ability this seat armed was consumed once, at fight start, and its
|
||||
// id parked on their statuses. Re-applying it here — not re-consuming —
|
||||
// is what makes a rage last the whole fight instead of one round.
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||
if err != nil {
|
||||
return player, enemy, err
|
||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||
}
|
||||
// Fold any fight-scoped buffs a mid-fight !cast / !consume layered on back
|
||||
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…)
|
||||
// live on the session's Statuses and flow through the turn engine's
|
||||
// resume/commit cycle, so only the persistent stat deltas are applied here.
|
||||
applySessionBuffs(&player, sess.Statuses)
|
||||
return player, enemy, err
|
||||
// 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, st)
|
||||
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
|
||||
// Party-only enemy HP bump, re-derived each turn from the template so
|
||||
// it never compounds. Matches the scalar startPartyCombatSession used
|
||||
// for the initial persist; solo (roster 1) scales by 1.0.
|
||||
if sess.IsParty() {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
return players, &enemy, nil
|
||||
}
|
||||
|
||||
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
|
||||
// needs: the Berserker's rage flag, which decides whether the character owes a
|
||||
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
||||
//
|
||||
// It re-applies the seat's armed ability to an empty mod set rather than
|
||||
// rebuilding the whole combatant: no Apply writes to the character (they read
|
||||
// level and HP and write only mods), so this is pure, and the passive/equipment
|
||||
// layers a full build would add are not read by any post-combat hook.
|
||||
//
|
||||
// The Grim Harvest pair is not re-derived but read back off the seat's
|
||||
// statuses, where castActionForSeat parked it: the spell that stashed it was
|
||||
// cast mid-fight, not at fight start, and nothing in the character sheet still
|
||||
// remembers which one it was.
|
||||
func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatModifiers {
|
||||
var mods CombatModifiers
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
applyAbilityByID(c, st.ArmedAbility, &mods)
|
||||
mods.GrimHarvestSlot = st.GrimHarvestSlot
|
||||
mods.GrimHarvestNecrotic = st.GrimHarvestNecrotic
|
||||
return mods
|
||||
}
|
||||
|
||||
// seatCombatResult reconstructs, for one seat of a finished turn-based fight,
|
||||
// the slice of CombatResult that the post-combat hooks actually read. The turn
|
||||
// engine never builds a CombatResult — it persists a session and a shared event
|
||||
// log — so the close-out has to assemble one.
|
||||
//
|
||||
// MistyHealed is read back off the seat's event log rather than off a flag: the
|
||||
// turn engine's CombatResult is a scratch value it discards between steps, and
|
||||
// the log is the only thing that survives to the close-out. This is how
|
||||
// combat_pet_save has always been detected.
|
||||
//
|
||||
// SniperKilled stays false. Arina's proc is a pre-combat one-shot, not a
|
||||
// round-end effect, so seatEndOfRound does not carry it and the turn engine
|
||||
// still has no place to fire it — combat_sniper_kill remains unreachable from a
|
||||
// manual kill.
|
||||
//
|
||||
// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max);
|
||||
// its loss-side meaning is unused here, since the only hook that reads it gates
|
||||
// on PlayerWon.
|
||||
func seatCombatResult(sess *CombatSession, seat int) CombatResult {
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
won := sess.Status == CombatStatusWon
|
||||
events := eventsForSeat(sess.TurnLog, seat)
|
||||
return CombatResult{
|
||||
PlayerWon: won,
|
||||
Events: events,
|
||||
PlayerEndHP: hp,
|
||||
EnemyEndHP: sess.EnemyHP,
|
||||
TotalRounds: sess.Round,
|
||||
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
|
||||
MistyHealed: hasAction(events, "misty_heal"),
|
||||
}
|
||||
}
|
||||
|
||||
// hasAction reports whether the event log holds at least one event with the
|
||||
// given Action. The one-line "did this happen in the fight?" scan the close-out
|
||||
// uses to read a proc's outcome off the log rather than a flag.
|
||||
func hasAction(events []CombatEvent, action string) bool {
|
||||
for _, e := range events {
|
||||
if e.Action == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a
|
||||
// terminal turn-based session — the bridge between what the turn engine
|
||||
// persisted and what the shared close-out expects.
|
||||
func (p *AdventurePlugin) postCombatBookkeepingForSeat(sess *CombatSession, seat int) {
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
// Loaded after the caller's persistDnDHPAfterCombat, so a Grim-Harvest-style
|
||||
// hook that heals off HPCurrent reads the fight's real ending HP.
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
slog.Error("combat: post-combat bookkeeping skipped, no sheet", "user", uid, "err", err)
|
||||
return
|
||||
}
|
||||
mods := seatFightStartMods(sess, seat, c)
|
||||
p.postCombatBookkeeping(uid, c, mods.BerserkerRage, seatCombatResult(sess, seat), mods)
|
||||
}
|
||||
|
||||
// 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 +284,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,
|
||||
})
|
||||
@@ -386,15 +454,23 @@ func TestApplySessionBuffs(t *testing.T) {
|
||||
func TestSeedCombatSessionOneShots(t *testing.T) {
|
||||
// Arcane Ward is the one resource normally live at fight start.
|
||||
s := &CombatSession{}
|
||||
if !seedCombatSessionOneShots(s, CombatModifiers{ArcaneWardHP: 15}) {
|
||||
if !seedCombatSessionOneShots(s, CombatSeatSetup{Mods: CombatModifiers{ArcaneWardHP: 15}}) {
|
||||
t.Error("seed should report true when Arcane Ward is present")
|
||||
}
|
||||
if s.Statuses.ArcaneWardHP != 15 {
|
||||
t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP)
|
||||
}
|
||||
// The armed ability rides along, so a rebuild mid-fight can re-apply it.
|
||||
armed := &CombatSession{}
|
||||
if !seedCombatSessionOneShots(armed, CombatSeatSetup{ArmedAbility: "rage"}) {
|
||||
t.Error("seed should report true when an ability was armed")
|
||||
}
|
||||
if armed.Statuses.ArmedAbility != "rage" {
|
||||
t.Errorf("ArmedAbility = %q, want rage", armed.Statuses.ArmedAbility)
|
||||
}
|
||||
// Nothing to seed → false, statuses untouched.
|
||||
empty := &CombatSession{}
|
||||
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
|
||||
if seedCombatSessionOneShots(empty, CombatSeatSetup{}) {
|
||||
t.Error("seed should report false with no fight-start resources")
|
||||
}
|
||||
}
|
||||
@@ -405,7 +481,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 +489,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 +501,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
|
||||
|
||||
petHitsOnPlayerTurn := func() int {
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
petHits := 0
|
||||
hits := 0
|
||||
for _, e := range events {
|
||||
if e.Action == "pet_attack" {
|
||||
petHits++
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
}
|
||||
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,
|
||||
player: player,
|
||||
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,8 +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:
|
||||
// stepEnemyTurn self-stamps each attack-action to the seat it targeted: a
|
||||
// party's enemy re-targets across the roster within one turn, so there is
|
||||
// no single seat the whole phase lands on. Solo stamps seat 0 throughout,
|
||||
// exactly as the old blanket stamp did.
|
||||
te.stepEnemyTurn()
|
||||
case CombatPhaseRoundEnd:
|
||||
te.stepRoundEnd()
|
||||
@@ -185,6 +430,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 +457,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 {
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
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 +509,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 +536,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 +557,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 +584,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,70 +599,148 @@ 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() {
|
||||
// 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.
|
||||
// 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 whole turn (every
|
||||
// attack-action) this round — unless the enemy is fear_immune, in which case
|
||||
// the control fizzled and it acts as normal.
|
||||
if te.st.enemySkipFirst {
|
||||
te.st.enemySkipFirst = false
|
||||
mark := len(te.st.events)
|
||||
if enemyImmuneToControl(te.enemy, te.st) {
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.stampSeat(mark, target)
|
||||
} else {
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
te.stampSeat(mark, target)
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
|
||||
// resolve their own damage and stand in for the attack action this round;
|
||||
// every other effect (poison / stun / enrage / armor_break) is a rider that
|
||||
// leaves the multiattack below intact. applyAbility returns true when the
|
||||
// player went down without a save.
|
||||
// Monster ability fires once, at the top of the enemy turn, against the
|
||||
// initial target. cleave / lifesteal resolve their own damage and stand in
|
||||
// for the enemy's first attack-action this round; every other effect (poison
|
||||
// / stun / enrage / armor_break) is a rider that leaves the multiattack below
|
||||
// intact. applyAbility returns true when the player went down without a save.
|
||||
abilityDealtDamage := false
|
||||
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
|
||||
if applyAbility(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
|
||||
mark := len(te.st.events)
|
||||
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
|
||||
te.stampSeat(mark, target)
|
||||
// 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
|
||||
}
|
||||
te.stampSeat(mark, target)
|
||||
switch te.enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
|
||||
if !abilityDealtDamage {
|
||||
// SRD multiattack: each profile entry is one attack roll resolved
|
||||
// through the shared primitive. A registered elite/boss swings its full
|
||||
// profile; everyone else gets a single attack from the template stats.
|
||||
// resolveEnemyAttack returns true when the fight is decided — either the
|
||||
// player went down without a death save, or a reflect consumable killed
|
||||
// the enemy. Disambiguate by inspecting HP.
|
||||
for _, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
||||
swing := *te.enemy
|
||||
swing.Stats.Attack = atk.Damage
|
||||
swing.Stats.AttackBonus = atk.AttackBonus
|
||||
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
// The enemy takes enemyActionsThisRound() attack-actions, each its full
|
||||
// SRD multiattack, re-targeting a fresh standing seat per action so a party's
|
||||
// damage is spread across the roster rather than pinning the first target.
|
||||
// A solo roster is exactly one action against its one seat — no re-target is
|
||||
// drawn — so its event stream and RNG draws are unchanged. When the ability
|
||||
// already dealt damage it was the first action, so one fewer follows; for solo
|
||||
// that collapses to the old "the ability stood in for the attack" skip.
|
||||
actions, reuseFirstTarget := enemyActionPlan(te.st, abilityDealtDamage)
|
||||
for a := 0; a < actions; a++ {
|
||||
if !(reuseFirstTarget && a == 0) {
|
||||
tgt, alive := te.enemyTarget()
|
||||
if !alive {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
te.st.seat(tgt)
|
||||
target = tgt
|
||||
}
|
||||
if te.enemyAttackAction(target) {
|
||||
return
|
||||
}
|
||||
}
|
||||
te.advance()
|
||||
}
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
|
||||
// enemyAttackAction resolves one enemy attack-action — the full SRD multiattack
|
||||
// profile — against the seat the cursor already points at, and stamps every event
|
||||
// it emits to that seat (the party's enemy re-targets across the roster within one
|
||||
// turn, so the whole-turn blanket stamp in step() no longer holds). Registered
|
||||
// elites/bosses swing their full profile; everyone else gets a single attack from
|
||||
// the template stats.
|
||||
//
|
||||
// Returns true only when it decided the fight (te.finish has already been called),
|
||||
// so the caller ends the enemy turn. It returns false when the target dropped but
|
||||
// the roster is still alive — this action is over, and the caller re-targets the
|
||||
// next one.
|
||||
func (te *turnEngine) enemyAttackAction(seat int) (finished bool) {
|
||||
mark := len(te.st.events)
|
||||
// Pet defensive procs are a single proc per attack-action: roll once, then
|
||||
// spend 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.
|
||||
// (This deliberately diverges from the auto-resolve engine's apply-to-all.)
|
||||
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
|
||||
}
|
||||
|
||||
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
||||
swing := *te.enemy
|
||||
swing.Stats.Attack = atk.Damage
|
||||
swing.Stats.AttackBonus = atk.AttackBonus
|
||||
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.stampSeat(mark, seat)
|
||||
// The target is down. The enemy stops swinging at a corpse; the fight
|
||||
// ends only if the roster is empty, otherwise this action is over and
|
||||
// the caller re-targets.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.stampSeat(mark, seat)
|
||||
te.finish(CombatStatusWon)
|
||||
return true
|
||||
}
|
||||
}
|
||||
te.stampSeat(mark, seat)
|
||||
return false
|
||||
}
|
||||
|
||||
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
|
||||
@@ -424,22 +768,70 @@ 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 && !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 — and before
|
||||
// Misty's crowd swings, so a caster whose aura would end the round is not
|
||||
// robbed of the win by an end-of-round debuff.
|
||||
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
|
||||
}
|
||||
}
|
||||
// Misty's crowd, then Misty's heal — per seat, after the round's other
|
||||
// damage (including the concentration pulse above) has landed so the heal
|
||||
// can answer it, which is the order endOfRoundForSeat uses. Both no-op (and
|
||||
// draw no RNG) for a character with no Misty history, which is every
|
||||
// simulated one.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, te.player, CombatPhaseRoundEnd) {
|
||||
continue
|
||||
}
|
||||
mark := len(st.events)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
|
||||
te.stampSeat(mark, i)
|
||||
if over {
|
||||
te.finish(CombatStatusLost)
|
||||
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 +841,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 +860,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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -410,22 +410,52 @@ func trySimAutoArm(c *DnDCharacter) string {
|
||||
return ab.Name
|
||||
}
|
||||
|
||||
// applyArmedAbility checks for a pre-armed ability on the character and
|
||||
// applies its effect to the player's CombatModifiers, then clears the armed
|
||||
// flag. Called from combat_bridge.go before SimulateCombat.
|
||||
func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
|
||||
// consumeArmedAbility disarms the character, returning the ability id that was
|
||||
// armed. It is the *mutating* half of arming: the flag is cleared and saved, so
|
||||
// it must run exactly once per fight, at fight start.
|
||||
//
|
||||
// It is split from applyAbilityByID because a turn-based fight rebuilds its
|
||||
// combatants from the DB on every player command. A rebuild that consumed would
|
||||
// fire the ability on round 1, clear the flag, and then hand every later round a
|
||||
// character with no ability at all — the player pays the resource for one round
|
||||
// of a buff that is supposed to span the fight. So the fight consumes once and
|
||||
// persists the id on the session; each rebuild re-applies it from there.
|
||||
//
|
||||
// An id that is no longer in the ability table is disarmed and reported as "".
|
||||
func consumeArmedAbility(c *DnDCharacter) string {
|
||||
if c == nil || c.ArmedAbility == "" {
|
||||
return "", false
|
||||
return ""
|
||||
}
|
||||
ab, ok := dndActiveAbilities[c.ArmedAbility]
|
||||
if !ok {
|
||||
armed := c.ArmedAbility
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
if _, ok := dndActiveAbilities[armed]; !ok {
|
||||
return ""
|
||||
}
|
||||
return armed
|
||||
}
|
||||
|
||||
// applyAbilityByID folds an ability's effect into a freshly-derived set of
|
||||
// CombatModifiers. It is pure with respect to persistence — no DB write, no
|
||||
// disarm — so it is safe to call on every rebuild of an in-flight fight.
|
||||
//
|
||||
// abilityID is what consumeArmedAbility returned at fight start. Empty (nobody
|
||||
// armed anything) and unknown ids are both no-ops.
|
||||
func applyAbilityByID(c *DnDCharacter, abilityID string, mods *CombatModifiers) (string, bool) {
|
||||
if c == nil || abilityID == "" {
|
||||
return "", false
|
||||
}
|
||||
ab, ok := dndActiveAbilities[abilityID]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
ab.Apply(c, mods)
|
||||
firedName := ab.Name
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
return firedName, true
|
||||
return ab.Name, true
|
||||
}
|
||||
|
||||
// armAbilityForFight consumes whatever the character armed and applies it in one
|
||||
// step, for the auto-resolve callers that build a combatant and immediately
|
||||
// fight with it. A turn-based fight must not use this — see consumeArmedAbility.
|
||||
func armAbilityForFight(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
|
||||
return applyAbilityByID(c, consumeArmedAbility(c), mods)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestSpendAndRefreshResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
||||
func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@arm_fighter:example")
|
||||
c := &DnDCharacter{
|
||||
@@ -162,7 +162,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
||||
}
|
||||
|
||||
mods := CombatModifiers{}
|
||||
name, fired := applyArmedAbility(c, &mods)
|
||||
name, fired := armAbilityForFight(c, &mods)
|
||||
if !fired {
|
||||
t.Fatal("ability did not fire")
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
|
||||
func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@arm_mage:example")
|
||||
c := &DnDCharacter{
|
||||
@@ -192,7 +192,7 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mods := CombatModifiers{}
|
||||
_, fired := applyArmedAbility(c, &mods)
|
||||
_, fired := armAbilityForFight(c, &mods)
|
||||
if !fired {
|
||||
t.Fatal("magic missile did not fire")
|
||||
}
|
||||
@@ -202,10 +202,10 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyArmedAbility_NoArm(t *testing.T) {
|
||||
func TestArmAbilityForFight_NoArm(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""}
|
||||
mods := CombatModifiers{}
|
||||
_, fired := applyArmedAbility(c, &mods)
|
||||
_, fired := armAbilityForFight(c, &mods)
|
||||
if fired {
|
||||
t.Error("fired with no armed ability")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -602,9 +602,18 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_infernax": {
|
||||
ID: "boss_infernax", Name: "Infernax the Undying",
|
||||
CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 11, Speed: 18,
|
||||
// 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.80, Effect: "stun"},
|
||||
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,
|
||||
@@ -652,7 +661,15 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_belaxath": {
|
||||
ID: "boss_belaxath", Name: "Belaxath the Undivided",
|
||||
CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 11, Speed: 14,
|
||||
// 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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
//
|
||||
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
|
||||
//
|
||||
// - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and
|
||||
// - DB-touching layers: applyMagicItemEffects, armAbilityForFight, and
|
||||
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
|
||||
// Go; tests run without a sqlite instance.
|
||||
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +213,9 @@ func (p *AdventurePlugin) handleResourceSellCmd(ctx MessageContext, args string)
|
||||
args = strings.TrimSpace(args)
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
// Post-expedition gate.
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
// Post-expedition gate. A seated member is mid-expedition too — reading
|
||||
// only their own row would let them run a shop from inside the dungeon.
|
||||
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't check expedition status.")
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ const (
|
||||
)
|
||||
|
||||
// ArmorProfile is the spec's struct. MaxDEXBonus convention:
|
||||
//
|
||||
// -1 = unlimited (light armor takes full DEX mod)
|
||||
// 2 = medium (cap at +2)
|
||||
// 0 = heavy (no DEX bonus)
|
||||
|
||||
@@ -53,6 +53,15 @@ type CampState struct {
|
||||
RoomIndex int `json:"room_index"`
|
||||
EstablishedAt time.Time `json:"established_at"`
|
||||
NightEvents []string `json:"night_events"`
|
||||
// RestApplied is set when the long-rest effects (HP refill, spell slots,
|
||||
// threat -5 etc.) have already been applied at pitch time. processOvernightCamp
|
||||
// uses it to skip re-applying so the night cycle just breaks the camp.
|
||||
RestApplied bool `json:"rest_applied,omitempty"`
|
||||
// AutoPitched is set when the long-expedition autopilot pitched this
|
||||
// camp. The autorun ticker breaks an auto-pitched camp itself after a
|
||||
// minimum dwell so the walk can keep moving; player-pitched camps stay
|
||||
// up until the player breaks them (or moves on).
|
||||
AutoPitched bool `json:"auto_pitched,omitempty"`
|
||||
}
|
||||
|
||||
// ThreatEvent — §8.4.
|
||||
@@ -184,21 +193,51 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
// expeditionSelectCols is the column list scanExpedition reads, in the order it
|
||||
// reads them. scanExpedition's Scan is positional, so every query that feeds it
|
||||
// must project exactly this — hence one constant rather than a copy per query.
|
||||
// The `e.` alias means a query can JOIN expedition_party without ambiguity; a
|
||||
// query with no JOIN just aliases dnd_expedition to e.
|
||||
const expeditionSelectCols = `
|
||||
e.expedition_id, e.user_id, e.zone_id, e.run_id, e.status,
|
||||
e.start_date, e.current_day, e.current_region, e.boss_defeated,
|
||||
e.supplies_json, e.camp_json, e.threat_level, e.threat_siege,
|
||||
e.threat_events, e.temporal_stack, e.region_state,
|
||||
e.xp_earned, e.coins_earned, e.gm_mood,
|
||||
e.last_briefing_at, e.last_recap_at, e.last_ambient_kind,
|
||||
e.last_activity, e.completed_at`
|
||||
|
||||
// getActiveExpedition returns the player's in-flight expedition, or (nil, nil).
|
||||
// 'extracting' rows are post-extraction (resumable) — see getResumableExpedition.
|
||||
func getActiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
row := db.Get().QueryRow(`
|
||||
SELECT expedition_id, user_id, zone_id, run_id, status,
|
||||
start_date, current_day, current_region, boss_defeated,
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition
|
||||
WHERE user_id = ?
|
||||
AND status = 'active'
|
||||
ORDER BY start_date DESC
|
||||
SELECT`+expeditionSelectCols+`
|
||||
FROM dnd_expedition e
|
||||
WHERE e.user_id = ?
|
||||
AND e.status = 'active'
|
||||
ORDER BY e.start_date DESC
|
||||
LIMIT 1`, string(userID))
|
||||
e, err := scanExpedition(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// ownedLiveExpedition returns the expedition this player owns and has not yet
|
||||
// finished with — 'active' or 'extracting'. An extracted expedition is not
|
||||
// over: it holds its roster for the whole resume window (releaseParty is not
|
||||
// called on it), so the owner is still the only person who can close it.
|
||||
//
|
||||
// Active rows sort first, so a leader who owns both — extracted from one, then
|
||||
// started another — resolves to the one they are standing in.
|
||||
func ownedLiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
row := db.Get().QueryRow(`
|
||||
SELECT`+expeditionSelectCols+`
|
||||
FROM dnd_expedition e
|
||||
WHERE e.user_id = ?
|
||||
AND e.status IN ('active', 'extracting')
|
||||
ORDER BY (e.status = 'active') DESC, e.start_date DESC
|
||||
LIMIT 1`, string(userID))
|
||||
e, err := scanExpedition(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -210,14 +249,8 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
// getExpedition fetches by ID regardless of status. Test/admin use.
|
||||
func getExpedition(id string) (*Expedition, error) {
|
||||
row := db.Get().QueryRow(`
|
||||
SELECT expedition_id, user_id, zone_id, run_id, status,
|
||||
start_date, current_day, current_region, boss_defeated,
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition WHERE expedition_id = ?`, id)
|
||||
SELECT`+expeditionSelectCols+`
|
||||
FROM dnd_expedition e WHERE e.expedition_id = ?`, id)
|
||||
e, err := scanExpedition(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
@@ -310,6 +343,42 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// withExpeditionSupplies serializes one read-modify-write of the shared supply
|
||||
// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds
|
||||
// its delta onto an *Expedition it read earlier silently discards anything that
|
||||
// landed in between — a member's pooled packs, another writer's spend. Handlers
|
||||
// run one goroutine per event, so those writers genuinely interleave.
|
||||
//
|
||||
// fn is handed a freshly-read expedition under the pool's own lock and returns
|
||||
// the supplies to persist. Callers that keep using their own *Expedition
|
||||
// afterwards must copy the returned pool back onto it.
|
||||
//
|
||||
// advUserLock cannot stand in here: it is keyed by sender, so two members
|
||||
// racing the same expedition row take two different mutexes and exclude nobody.
|
||||
func (p *AdventurePlugin) withExpeditionSupplies(
|
||||
expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error),
|
||||
) (ExpeditionSupplies, error) {
|
||||
mu := p.advExpeditionLock(expID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
fresh, err := getExpedition(expID)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if fresh == nil {
|
||||
return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID)
|
||||
}
|
||||
next, err := fn(fresh)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if err := updateSupplies(expID, next); err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// updateCamp persists camp state. Pass nil to break camp.
|
||||
func updateCamp(expID string, c *CampState) error {
|
||||
var arg any
|
||||
@@ -338,34 +407,73 @@ func setExpeditionRunID(expID, runID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// abandonExpedition flags the active expedition as abandoned. Idempotent.
|
||||
// abandonExpedition flags the player's live expedition as abandoned. Idempotent.
|
||||
//
|
||||
// It spans 'extracting' as well as 'active': an extracted expedition still owns
|
||||
// its roster, so abandoning is the leader's only way to free their party
|
||||
// without paying to `!resume` first. Character reset (dnd_setup) leans on this
|
||||
// too — a rerolled leader who left an extracted party behind would otherwise
|
||||
// strand every member until the sweeper reaped the row.
|
||||
//
|
||||
// One row per call, active first, so the run-spawn rollback in expeditionCmdStart
|
||||
// tears down the expedition it just created rather than an older extracted one.
|
||||
func abandonExpedition(userID id.UserID) error {
|
||||
e, err := getActiveExpedition(userID)
|
||||
e, err := ownedLiveExpedition(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e == nil {
|
||||
return ErrNoActiveExpedition
|
||||
}
|
||||
_, err = db.Get().Exec(`
|
||||
if _, err = db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET status = 'abandoned',
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
last_activity = CURRENT_TIMESTAMP
|
||||
WHERE expedition_id = ?`, e.ID)
|
||||
WHERE expedition_id = ?`, e.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
releaseParty(e.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// completeExpedition marks the expedition complete (boss defeated or extracted).
|
||||
func completeExpedition(expID string, status string) error {
|
||||
_, err := db.Get().Exec(`
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET status = ?,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
last_activity = CURRENT_TIMESTAMP
|
||||
WHERE expedition_id = ?`, status, expID)
|
||||
WHERE expedition_id = ?`, status, expID); err != nil {
|
||||
return err
|
||||
}
|
||||
releaseParty(expID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseParty clears the roster of an expedition that has reached a terminal
|
||||
// status, freeing every member to start a run of their own. A member is barred
|
||||
// from adventuring anywhere else while seated (assertNotAdventuring), so a
|
||||
// roster that outlives its expedition strands the whole party.
|
||||
//
|
||||
// It is deliberately *not* called on the 'extracting' status: that is a
|
||||
// seven-day resumable limbo, and `!resume` must bring the party back with it.
|
||||
// The roster is cleared when the resume window lapses and the row flips to
|
||||
// 'failed' — which routes through completeExpedition like everything else.
|
||||
//
|
||||
// A failure here is logged, not returned: the expedition is already terminal by
|
||||
// the time we get here, and refusing to finish it over a stale roster row would
|
||||
// leave the leader stuck instead of the members.
|
||||
func releaseParty(expID string) {
|
||||
if err := disbandParty(expID); err != nil {
|
||||
slog.Warn("expedition: disband party", "expedition", expID, "err", err)
|
||||
}
|
||||
// An unanswered invite to a finished expedition would otherwise sit there
|
||||
// until its TTL, letting someone accept their way onto a corpse.
|
||||
if err := clearExpeditionInvites(expID); err != nil {
|
||||
slog.Warn("expedition: clear invites", "expedition", expID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyThreatDelta clamps the threat level to [0,100], records the event,
|
||||
// and flips siege_mode when the level reaches 100.
|
||||
@@ -433,6 +541,34 @@ func appendExpeditionLog(expID string, day int, entryType, summary, flavor strin
|
||||
return err
|
||||
}
|
||||
|
||||
// dayExpeditionLog returns every log entry recorded against the given
|
||||
// (expedition, day) pair, oldest first. Used by the D4-a end-of-day
|
||||
// digest to bundle a single rollup DM at night-camp pitch.
|
||||
func dayExpeditionLog(expID string, day int) ([]ExpeditionEntry, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor
|
||||
FROM dnd_expedition_log
|
||||
WHERE expedition_id = ?
|
||||
AND day = ?
|
||||
ORDER BY entry_id`, expID, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ExpeditionEntry
|
||||
for rows.Next() {
|
||||
var e ExpeditionEntry
|
||||
if err := rows.Scan(
|
||||
&e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp,
|
||||
&e.Type, &e.Summary, &e.Flavor,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// recentExpeditionLog returns the last `limit` entries, newest first.
|
||||
func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) {
|
||||
if limit <= 0 {
|
||||
@@ -468,4 +604,3 @@ func nullableString(s string) any {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -58,7 +59,7 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
|
||||
"No Adv 2.0 character yet — run `!setup` first.")
|
||||
}
|
||||
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
@@ -72,6 +73,12 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
|
||||
if requested == "" {
|
||||
return p.SendDM(ctx.Sender, campHelpText(exp))
|
||||
}
|
||||
// Bare `!camp` reads the party's tent; pitching and striking it are the
|
||||
// leader's, since the camp is a property of the expedition everyone rides.
|
||||
if !isLeader {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Your party leader pitches the camp. `!camp` on its own shows what's already up.")
|
||||
}
|
||||
if requested == "break" || requested == "down" || requested == "leave" {
|
||||
return p.campBreak(ctx, exp)
|
||||
}
|
||||
@@ -79,12 +86,9 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
|
||||
case "rough", "standard":
|
||||
// allowed in E1e
|
||||
case "fortified":
|
||||
// E2d: §5.1 — boss-cleared room or cache site. Cache sites
|
||||
// are zone-specific waypoints (E3+), so for now we require the
|
||||
// expedition's boss to have been defeated.
|
||||
if !exp.BossDefeated {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Fortified camps require a boss-cleared room or cache site. Defeat the zone boss (or find a cache) first.")
|
||||
"Fortified camps require a defeated zone boss. Clear the zone first.")
|
||||
}
|
||||
case "base":
|
||||
// E4d: §11.1 — base camps unlock per region after the region
|
||||
@@ -119,10 +123,10 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
|
||||
|
||||
func campHelpText(exp *Expedition) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("**!camp <type>** — establish camp during an expedition.\n\n")
|
||||
b.WriteString("**!camp <type>** — _override._ Autopilot pitches camp at night automatically; reach for this only to force a rest right now.\n\n")
|
||||
b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n")
|
||||
b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n")
|
||||
b.WriteString("`!camp fortified` — long rest + bonus (boss-cleared room, +2 SU, low risk)\n")
|
||||
b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n")
|
||||
b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n")
|
||||
b.WriteString("`!camp break` — break camp\n\n")
|
||||
if exp.Camp != nil && exp.Camp.Active {
|
||||
@@ -146,10 +150,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
||||
return p.SendDM(ctx.Sender, problem)
|
||||
}
|
||||
if !cleared && kind == CampTypeStandard {
|
||||
// §5.2: non-cleared room forces rough.
|
||||
kind = CampTypeRough
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||
"intended standard camp; downgraded to rough (room not cleared)", "")
|
||||
// §5.2: standard camp requires a cleared room. Reject explicitly
|
||||
// rather than silently downgrading — the player should make the call.
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Standard camp needs a cleared room. This room isn't cleared yet — clear it first, or `!camp rough` for a partial rest here.")
|
||||
}
|
||||
|
||||
cost, ok := campSupplyCost[kind]
|
||||
@@ -162,10 +166,42 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
||||
cost, exp.Supplies.Current))
|
||||
}
|
||||
|
||||
exp.Supplies.Current -= cost
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
// D2-b: event-anchored manual !camp counts as the night camp if it's
|
||||
// the first camp since the last rollover. Burn supplies *before* the
|
||||
// camp cost so the burn lands against pre-pitch supplies (matches the
|
||||
// legacy morning-burn ordering), then pitch, then drift.
|
||||
nightCamp := false
|
||||
var nightBurn float32
|
||||
var nightRoll nightRolloverResult
|
||||
if isEventAnchored(exp) {
|
||||
var since time.Duration
|
||||
if exp.LastBriefingAt != nil {
|
||||
since = time.Now().UTC().Sub(*exp.LastBriefingAt)
|
||||
} else {
|
||||
since = time.Now().UTC().Sub(exp.StartDate)
|
||||
}
|
||||
if since >= nightCampWindow {
|
||||
nightCamp = true
|
||||
burn, err := p.nightRolloverBurn(exp)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't burn night supplies: "+err.Error())
|
||||
}
|
||||
nightBurn = burn
|
||||
}
|
||||
}
|
||||
|
||||
// The affordability check above ran against `exp` as it was read, and
|
||||
// nightRolloverBurn may have moved the pool since. Deduct against the pool
|
||||
// as it stands — the same arithmetic, just not onto a stale snapshot.
|
||||
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
next := fresh.Supplies
|
||||
next.Current -= cost
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
|
||||
}
|
||||
exp.Supplies = pooled
|
||||
camp := &CampState{
|
||||
Active: true,
|
||||
Type: kind,
|
||||
@@ -176,6 +212,20 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
||||
if err := updateCamp(exp.ID, camp); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error())
|
||||
}
|
||||
exp.Camp = camp
|
||||
|
||||
// Apply the long-rest effects immediately so the player isn't waiting
|
||||
// until the next 06:00 UTC briefing for HP and spell slots to come
|
||||
// back. The flag tells processOvernightCamp not to re-apply at briefing.
|
||||
restSummary := applyCampRest(exp, kind)
|
||||
if nightCamp {
|
||||
nightRoll = p.nightRolloverDrift(exp, time.Now().UTC())
|
||||
nightRoll.Burn = nightBurn
|
||||
}
|
||||
camp.RestApplied = true
|
||||
if err := updateCamp(exp.ID, camp); err != nil {
|
||||
slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err)
|
||||
}
|
||||
|
||||
// E4d: pick the BaseCampEstablished pool for base camps; otherwise
|
||||
// the generic camp pool. Both already handle [N] day interpolation
|
||||
@@ -206,40 +256,33 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
||||
if line != "" {
|
||||
b.WriteString("\n" + line + "\n")
|
||||
}
|
||||
if restSummary != "" {
|
||||
b.WriteString("\n" + restSummary + "\n")
|
||||
}
|
||||
switch kind {
|
||||
case CampTypeRough:
|
||||
b.WriteString("\n_Rough camp — partial rest. HP recovers to 50%, no spell slots restored._")
|
||||
case CampTypeFortified:
|
||||
b.WriteString("\n_Fortified camp — long rest + 1d6 HP bonus on wake; threat clock −5; wandering rolls −4._")
|
||||
case CampTypeBase:
|
||||
b.WriteString("\n_Base camp — long rest + 1d6 HP bonus; threat clock −5; wandering rolls −6. **Waypoint persisted** — camp here again at no eligibility cost on later returns._")
|
||||
default:
|
||||
b.WriteString("\n_Standard camp — full long rest at the next morning briefing._")
|
||||
b.WriteString("\n_Base camp — **waypoint persisted**. Camp here again at no eligibility cost on later returns._")
|
||||
}
|
||||
if nightCamp {
|
||||
b.WriteString(fmt.Sprintf("\n\n🌙 **Day %d.** _Overnight burn: %.1f SU._\n", exp.CurrentDay, nightRoll.Burn))
|
||||
for _, tl := range nightRoll.TemporalLines {
|
||||
b.WriteString("\n🌀 " + tl + "\n")
|
||||
}
|
||||
for _, ml := range nightRoll.MilestoneLines {
|
||||
b.WriteString("\n" + ml)
|
||||
}
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
// processOvernightCamp applies the overnight long-rest effects of an
|
||||
// active camp at briefing time (§3, §5.1, §8.1). Auto-breaks the camp
|
||||
// after the rest. Returns a one-line summary for the briefing body.
|
||||
//
|
||||
// Effects:
|
||||
// - rough: HP recovered to at least 50% of max.
|
||||
// - standard: HP fully restored, spell slots refreshed, exhaustion -1.
|
||||
// - fortified: standard + 1d6 HP bonus on top, threat -5.
|
||||
// - base: same as fortified for the rest itself; persistent waypoint
|
||||
// mechanics land in E4.
|
||||
//
|
||||
// Returns "" if the expedition wasn't camped overnight.
|
||||
func processOvernightCamp(e *Expedition) string {
|
||||
if e.Camp == nil || !e.Camp.Active {
|
||||
return ""
|
||||
}
|
||||
// applyCampRest runs the long-rest effects (HP/spells/threat/heat) for the
|
||||
// given camp kind and returns a player-facing summary. Shared by camp pitch
|
||||
// (immediate rest) and overnight rollover (legacy deferred path). Does NOT
|
||||
// break the camp — callers decide that.
|
||||
func applyCampRest(e *Expedition, kind string) string {
|
||||
uid := id.UserID(e.UserID)
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
if c == nil {
|
||||
// No character to apply HP/spells to; just break the camp.
|
||||
_ = updateCamp(e.ID, nil)
|
||||
return ""
|
||||
}
|
||||
// Babysit safe-rest: an active subscription promotes a Standard camp
|
||||
@@ -247,7 +290,6 @@ func processOvernightCamp(e *Expedition) string {
|
||||
// Rough/Base are unchanged — Rough still implies no shelter, and Base
|
||||
// already exceeds Fortified.
|
||||
babysitUpgraded := false
|
||||
kind := e.Camp.Type
|
||||
if kind == CampTypeStandard && BabysitSafeRest(uid) {
|
||||
kind = CampTypeFortified
|
||||
babysitUpgraded = true
|
||||
@@ -299,11 +341,6 @@ func processOvernightCamp(e *Expedition) string {
|
||||
heatReduced = before - after
|
||||
}
|
||||
|
||||
// Auto-break the camp now that the rest has been applied.
|
||||
_ = updateCamp(e.ID, nil)
|
||||
e.Camp = nil
|
||||
|
||||
// Pretty summary for the briefing body.
|
||||
switch kind {
|
||||
case CampTypeRough:
|
||||
if c.HPCurrent > prevHP {
|
||||
@@ -327,6 +364,24 @@ func processOvernightCamp(e *Expedition) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// processOvernightCamp handles the briefing-time half of the camp lifecycle:
|
||||
// auto-breaks the camp, and applies the long rest only if it wasn't already
|
||||
// applied at pitch time. Returns a one-line summary for the briefing body, or
|
||||
// "" if there was nothing to do.
|
||||
func processOvernightCamp(e *Expedition) string {
|
||||
if e.Camp == nil || !e.Camp.Active {
|
||||
return ""
|
||||
}
|
||||
kind := e.Camp.Type
|
||||
alreadyApplied := e.Camp.RestApplied
|
||||
_ = updateCamp(e.ID, nil)
|
||||
e.Camp = nil
|
||||
if alreadyApplied {
|
||||
return ""
|
||||
}
|
||||
return applyCampRest(e, kind)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error {
|
||||
if exp.Camp == nil || !exp.Camp.Active {
|
||||
return p.SendDM(ctx.Sender, "No camp to break.")
|
||||
@@ -359,15 +414,66 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
|
||||
case RoomTrap:
|
||||
return false, "You can't camp in a trap room — even a disarmed one."
|
||||
}
|
||||
// Active-enemy detection requires combat-state lookup; defer to E2.
|
||||
cleared = false
|
||||
for _, idx := range run.RoomsCleared {
|
||||
if idx == run.CurrentRoom {
|
||||
cleared = true
|
||||
break
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return cleared, ""
|
||||
// Not yet advanced-past, but the only thing that bars a rest is a live
|
||||
// fight. Forward-only navigation means players pause right after a kill
|
||||
// before advancing, and peaceful/exploration/loot rooms never spawn an
|
||||
// encounter at all — both are safe to rest in. The "cleared" flag would
|
||||
// otherwise reject standard camp here with a misleading "clear it first".
|
||||
encID := encounterIDForRoom(run.CurrentRoom)
|
||||
sess, err := getCombatSessionForEncounter(run.RunID, encID)
|
||||
if err != nil {
|
||||
// Can't read the encounter's combat state. Fail open like the
|
||||
// run-lookup path above rather than blocking standard camp with an
|
||||
// empty (misleading "not cleared") rejection — a DB hiccup shouldn't
|
||||
// strand a player who only wants to rest.
|
||||
slog.Warn("camp: combat session lookup failed; allowing camp",
|
||||
"run", run.RunID, "encounter", encID, "err", err)
|
||||
return true, ""
|
||||
}
|
||||
if sess != nil && sess.Status == CombatStatusActive {
|
||||
return false, "You can't camp mid-fight — finish the encounter first."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// autoBreakCampOnMove strikes an active camp when the player has moved
|
||||
// to a different room than the one camp was pitched in. Camp is a
|
||||
// stationary intent (long-rest at this spot until the next briefing);
|
||||
// once the party walks on — whether by autopilot, manual !advance, or
|
||||
// fork pick — the tent stops mattering. Overnight rest effects are not
|
||||
// applied here (those only land at briefing time via
|
||||
// processOvernightCamp). Returns the camp type that was struck, or ""
|
||||
// if no break happened. Safe to call when there's no expedition.
|
||||
func autoBreakCampOnMove(userID id.UserID) string {
|
||||
exp, err := getActiveExpedition(userID)
|
||||
if err != nil || exp == nil {
|
||||
return ""
|
||||
}
|
||||
if exp.Camp == nil || !exp.Camp.Active {
|
||||
return ""
|
||||
}
|
||||
if exp.RunID == "" {
|
||||
return ""
|
||||
}
|
||||
run, err := getZoneRun(exp.RunID)
|
||||
if err != nil || run == nil {
|
||||
return ""
|
||||
}
|
||||
if run.CurrentRoom == exp.Camp.RoomIndex {
|
||||
return ""
|
||||
}
|
||||
kind := exp.Camp.Type
|
||||
if err := updateCamp(exp.ID, nil); err != nil {
|
||||
slog.Warn("camp: auto-break on move failed", "expedition", exp.ID, "err", err)
|
||||
return ""
|
||||
}
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp struck (party moved on)", "")
|
||||
return kind
|
||||
}
|
||||
|
||||
// campCurrentRoomIndex returns 0 (entry) when no room context exists.
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -47,8 +48,9 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
sub, rest := splitFirstWord(args)
|
||||
switch strings.ToLower(sub) {
|
||||
case "":
|
||||
// If active, show status; otherwise help.
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
// If active, show status; otherwise help. A party member is on an
|
||||
// expedition they do not own, so ask the leader-or-member resolver.
|
||||
if exp, _, _ := activeExpeditionFor(ctx.Sender); exp != nil {
|
||||
return p.expeditionCmdStatus(ctx, "")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||||
@@ -64,8 +66,16 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
// player doesn't have to remember the !zone seam). Otherwise
|
||||
// fall back to the historical alias for `!expedition start`.
|
||||
if rest != "" && isAllDigits(strings.TrimSpace(rest)) {
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
exp, isLeader, _ := activeExpeditionFor(ctx.Sender)
|
||||
switch {
|
||||
case exp != nil && isLeader:
|
||||
return p.zoneCmdGo(ctx, rest)
|
||||
case exp != nil:
|
||||
// A member reaching a fork must not silently fall through to
|
||||
// `!expedition start` and be told their path number is an
|
||||
// unknown zone. The leader picks the way (C1); P6c enforces
|
||||
// that at the !zone seam too.
|
||||
return p.SendDM(ctx.Sender, "The party leader picks the path.")
|
||||
}
|
||||
}
|
||||
return p.expeditionCmdStart(ctx, c, rest)
|
||||
@@ -75,6 +85,16 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
return p.expeditionCmdLog(ctx)
|
||||
case "abandon", "quit":
|
||||
return p.expeditionCmdAbandon(ctx)
|
||||
case "invite":
|
||||
return p.expeditionCmdInvite(ctx, rest)
|
||||
case "accept", "join":
|
||||
return p.expeditionCmdAccept(ctx, c, rest)
|
||||
case "decline":
|
||||
return p.expeditionCmdDecline(ctx)
|
||||
case "party", "roster":
|
||||
return p.expeditionCmdParty(ctx)
|
||||
case "leave":
|
||||
return p.expeditionCmdLeave(ctx)
|
||||
case "extract":
|
||||
return p.handleExtractCmd(ctx, "")
|
||||
case "resume":
|
||||
@@ -91,18 +111,28 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
func expeditionHelpText() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n")
|
||||
b.WriteString("`!expedition list` — show zones available at your level\n")
|
||||
b.WriteString("`!expedition start <zone> [Ns] [Md]` — outfit & begin\n")
|
||||
b.WriteString(" `Ns` = N standard packs (10 SU, 50 coins, max 3)\n")
|
||||
b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins, max 1)\n")
|
||||
b.WriteString(" default: `1s`\n")
|
||||
b.WriteString("`!expedition run` — autopilot: walk rooms until something needs you (alias `!explore`)\n")
|
||||
b.WriteString("`!expedition status` — current expedition snapshot\n")
|
||||
b.WriteString("**The shape:** pick a zone, pick a supply pack, watch it play out. ")
|
||||
b.WriteString("Autopilot walks rooms, pitches camp at night, and DMs you when something needs a decision (a fork, a boss, low HP).\n\n")
|
||||
b.WriteString("**Run an expedition:**\n")
|
||||
b.WriteString("`!expedition list` — zones available at your level\n")
|
||||
b.WriteString("`!expedition start <zone>` — prompts a loadout: `lean` / `balanced` / `heavy`\n")
|
||||
b.WriteString("`!expedition run` — start (or resume) the autopilot walk (alias `!explore`)\n")
|
||||
b.WriteString("`!expedition status` — day, rooms, supplies, recent events\n\n")
|
||||
b.WriteString("**Bring someone** _(Day 1, up to three of you)_:\n")
|
||||
b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
|
||||
b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
|
||||
b.WriteString("`!expedition party` — who's with you\n")
|
||||
b.WriteString("`!expedition leave` — turn back for town (members only)\n\n")
|
||||
b.WriteString("**Mid-expedition:**\n")
|
||||
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
|
||||
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
|
||||
b.WriteString("`!expedition log` — last 5 log entries\n")
|
||||
b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n")
|
||||
b.WriteString("`!extract` — voluntary extraction (1 day, resumable for 7 days)\n")
|
||||
b.WriteString("`!resume [Ns] [Md]` — resume an extracted expedition\n")
|
||||
b.WriteString("`!map` — region/room ASCII map")
|
||||
b.WriteString("`!expedition abandon` — end without rewards\n")
|
||||
b.WriteString("`!map` — region/room ASCII map\n\n")
|
||||
b.WriteString("**Overrides** _(autopilot covers these — only reach for them if you want manual control)_:\n")
|
||||
b.WriteString("`!fight` — engage an Elite/Boss the autopilot paused at\n")
|
||||
b.WriteString("`!camp` — force a rest right now (see `!camp` for types)\n")
|
||||
b.WriteString("`!expedition start <zone> Ns Md` — raw pack counts instead of a preset")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -124,10 +154,14 @@ func (p *AdventurePlugin) expeditionCmdList(ctx MessageContext, c *DnDCharacter)
|
||||
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||||
}
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
if exp, isLeader, _ := activeExpeditionFor(ctx.Sender); exp != nil {
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. Use `!expedition status` or `!expedition abandon`._",
|
||||
zone.Display, exp.CurrentDay))
|
||||
tail := "Use `!expedition status` or `!expedition abandon`."
|
||||
if !isLeader {
|
||||
tail = "Use `!expedition status` or `!expedition leave`."
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. %s_",
|
||||
zone.Display, exp.CurrentDay, tail))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
@@ -178,6 +212,66 @@ func parseSupplyArgs(rest string) (SupplyPurchase, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// resolveLoadoutOrParse first tries a single-token preset (lean/balanced/
|
||||
// heavy and short forms); on miss it falls back to raw `Ns Md` parsing.
|
||||
// Tier is needed because preset purchase counts scale by zone tier.
|
||||
func resolveLoadoutOrParse(tok string, tier ZoneTier) (SupplyPurchase, error) {
|
||||
trimmed := strings.TrimSpace(tok)
|
||||
if !strings.ContainsAny(trimmed, " \t") {
|
||||
if l, ok := parseLoadoutToken(trimmed); ok {
|
||||
return loadoutPurchase(tier, l), nil
|
||||
}
|
||||
}
|
||||
return parseSupplyArgs(tok)
|
||||
}
|
||||
|
||||
// renderLoadoutPrompt formats the "pick your loadout" DM. The resume
|
||||
// command and start command share it; cmdHint tells the player which
|
||||
// command to type back with the chosen preset.
|
||||
func renderLoadoutPrompt(zone ZoneDefinition, cmdHint string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🎒 **Pick a loadout — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||||
for _, l := range []SupplyLoadout{LoadoutLean, LoadoutBalanced, LoadoutHeavy} {
|
||||
pp := loadoutPurchase(zone.Tier, l)
|
||||
b.WriteString(fmt.Sprintf(" `%s` — %s — %.0f SU, %d coins — %s\n",
|
||||
loadoutName(l), packBreakdown(pp), pp.Total(), pp.Cost(), loadoutBlurb(l)))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nType `!%s %s` (or `lean` / `heavy`).\n", cmdHint, loadoutName(LoadoutBalanced)))
|
||||
b.WriteString("Advanced: raw pack counts like `2s 1d`.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func loadoutName(l SupplyLoadout) string {
|
||||
switch l {
|
||||
case LoadoutLean:
|
||||
return "lean"
|
||||
case LoadoutHeavy:
|
||||
return "heavy"
|
||||
}
|
||||
return "balanced"
|
||||
}
|
||||
|
||||
func loadoutBlurb(l SupplyLoadout) string {
|
||||
switch l {
|
||||
case LoadoutLean:
|
||||
return "covers the intended run at calm burn; thin if things go sideways"
|
||||
case LoadoutHeavy:
|
||||
return "max cap; rides out harsh stretches and overruns"
|
||||
}
|
||||
return "recommended; absorbs a harsh patch or two"
|
||||
}
|
||||
|
||||
func packBreakdown(p SupplyPurchase) string {
|
||||
switch {
|
||||
case p.StandardPacks > 0 && p.DeluxePacks > 0:
|
||||
return fmt.Sprintf("%d standard + %d deluxe", p.StandardPacks, p.DeluxePacks)
|
||||
case p.DeluxePacks > 0:
|
||||
return fmt.Sprintf("%d deluxe", p.DeluxePacks)
|
||||
default:
|
||||
return fmt.Sprintf("%d standard", p.StandardPacks)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error {
|
||||
if rest == "" {
|
||||
return p.SendDM(ctx.Sender,
|
||||
@@ -195,13 +289,71 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Unknown zone for your level. Try `!expedition list`.")
|
||||
}
|
||||
purchase, err := parseSupplyArgs(packTok)
|
||||
zoneForCaps, _ := getZone(zoneID)
|
||||
// D5-b: prompt for a preset loadout on empty args. Raw `Ns Md` syntax
|
||||
// still works as the advanced override.
|
||||
if strings.TrimSpace(packTok) == "" {
|
||||
return p.SendDM(ctx.Sender, renderLoadoutPrompt(zoneForCaps, "expedition start "+string(zoneID)))
|
||||
}
|
||||
purchase, err := resolveLoadoutOrParse(packTok, zoneForCaps.Tier)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
||||
}
|
||||
if err := purchase.Validate(); err != nil {
|
||||
if err := purchase.Validate(zoneForCaps.Tier); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
||||
}
|
||||
// Reject if any expedition or zone run already active. This runs before the
|
||||
// price quote: a player who cannot leave doesn't need to hear what leaving
|
||||
// would have cost.
|
||||
//
|
||||
// The seat check spans `extracting` as well as `active` — a member of an
|
||||
// extracting party is still seated for the seven-day resume window, and
|
||||
// letting them outfit a rival expedition double-books them the moment their
|
||||
// leader types `!resume`.
|
||||
if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil {
|
||||
zone, _ := getZone(seated.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
|
||||
zone.Display, seated.CurrentDay))
|
||||
}
|
||||
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
|
||||
zone, _ := getZone(existing.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
}
|
||||
// A leader who extracted still holds their roster for the resume window, and
|
||||
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
||||
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
||||
// catches it, with every member still seated and refused a run of their own.
|
||||
//
|
||||
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
||||
// walking away from it stays a normal thing to do.
|
||||
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
|
||||
switch {
|
||||
case extractionLapsed(pending, time.Now().UTC()):
|
||||
// Past the window — reap it here rather than make them wait an hour
|
||||
// for the sweeper, and let the new expedition proceed. Route through
|
||||
// the shared reap so the freed members hear about it, same as the
|
||||
// sweeper and `!expedition abandon` do.
|
||||
if err := p.reapLapsedExtraction(pending); err != nil {
|
||||
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
||||
}
|
||||
default:
|
||||
// A roster still holds; block. On a roster-read error, assume it is
|
||||
// occupied and refuse — proceeding would orphan a party we could not
|
||||
// confirm was empty, the one outcome this guard exists to prevent. A
|
||||
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
||||
n, err := partySize(pending.ID)
|
||||
if err != nil || n > 1 {
|
||||
zone, _ := getZone(pending.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
||||
zone.Display, pending.CurrentDay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cost := float64(purchase.Cost())
|
||||
if p.euro == nil {
|
||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
||||
@@ -211,14 +363,6 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||
int(cost), balance))
|
||||
}
|
||||
|
||||
// Reject if any expedition or zone run already active.
|
||||
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
|
||||
zone, _ := getZone(existing.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
}
|
||||
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
|
||||
zone, _ := getZone(existing.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
@@ -226,9 +370,10 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
zone.Display))
|
||||
}
|
||||
|
||||
zone, _ := getZone(zoneID)
|
||||
zone := zoneForCaps
|
||||
// Holiday perk: a complimentary standard pack is added to the supplies
|
||||
// snapshot without inflating the coin cost.
|
||||
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
||||
// on purpose — it's a freebie on top of whatever the player bought.
|
||||
suppliesPurchase := purchase
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
@@ -261,6 +406,7 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
// Log the start with prewritten flavor.
|
||||
startLine := flavor.Pick(flavor.ExpeditionStart)
|
||||
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
||||
markActedToday(ctx.Sender)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||||
@@ -319,7 +465,7 @@ func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext, args string) e
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool) error {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
@@ -330,8 +476,9 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
c, _ := LoadDnDCharacter(ctx.Sender)
|
||||
|
||||
target := expeditionTargetDays(zone.Tier)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d**\n\n", exp.CurrentDay))
|
||||
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d / ~%d expected**\n\n", exp.CurrentDay, target))
|
||||
b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier)))
|
||||
if r, ok := CurrentRegion(exp); ok {
|
||||
cleared := IsRegionCleared(exp, r.ID)
|
||||
@@ -342,6 +489,10 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool
|
||||
b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n",
|
||||
r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker))
|
||||
}
|
||||
if run, _, rerr := activeZoneRunFor(ctx.Sender); rerr == nil && run != nil && run.TotalRooms > 0 {
|
||||
b.WriteString(fmt.Sprintf("🚪 **Rooms:** %d / %d in this region\n",
|
||||
run.CurrentRoom+1, run.TotalRooms))
|
||||
}
|
||||
if c != nil {
|
||||
b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax))
|
||||
}
|
||||
@@ -376,6 +527,19 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool
|
||||
depletionLabel(state)))
|
||||
}
|
||||
}
|
||||
if entries, lerr := recentExpeditionLog(exp.ID, 3); lerr == nil && len(entries) > 0 {
|
||||
b.WriteString("\n**Recent:**\n")
|
||||
for _, e := range entries {
|
||||
line := e.Summary
|
||||
if line == "" {
|
||||
line = e.Flavor
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" · _Day %d_ — %s\n", e.Day, line))
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s",
|
||||
exp.StartDate.Format("2006-01-02 15:04"),
|
||||
exp.LastActivity.Format("2006-01-02 15:04")))
|
||||
@@ -437,7 +601,7 @@ func depletionLabel(s SupplyDepletionState) string {
|
||||
// ── log ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
@@ -473,22 +637,64 @@ func formatLogTimestamp(t time.Time) string {
|
||||
// ── abandon ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
if exp == nil {
|
||||
// An extracted expedition is still the owner's to close — it holds the
|
||||
// roster until the resume window lapses. Without this, a leader who
|
||||
// wanted out had to pay to `!resume` first just to abandon.
|
||||
if exp, err = getResumableExpedition(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
isLeader = exp != nil
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
|
||||
}
|
||||
if !isLeader {
|
||||
// Abandoning throws away everyone's day. A member leaves alone.
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
|
||||
}
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
extracted := exp.Status == ExpeditionStatusExtracting
|
||||
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
|
||||
if err := abandonExpedition(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
||||
}
|
||||
markActedToday(ctx.Sender)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
// An extracted party is standing in town, not in the dungeon: their supplies
|
||||
// are already spent and their loot is already banked. Say the true thing.
|
||||
body := fmt.Sprintf(
|
||||
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
||||
zone.Display, exp.CurrentDay))
|
||||
zone.Display, exp.CurrentDay)
|
||||
if extracted {
|
||||
body = fmt.Sprintf(
|
||||
"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
|
||||
zone.Display, exp.CurrentDay)
|
||||
}
|
||||
// The roster is being disbanded out from under the members; they hear it from
|
||||
// their leader rather than discovering it the next time a command works again.
|
||||
for _, uid := range audience {
|
||||
if uid == ctx.Sender {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(uid, fmt.Sprintf(
|
||||
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
|
||||
zone.Display, exp.CurrentDay)); err != nil {
|
||||
slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
|
||||
}
|
||||
}
|
||||
if err := p.SendDM(ctx.Sender, body); err != nil {
|
||||
return err
|
||||
}
|
||||
// Emergence seam: see maybeRollPetArrivalOnEmerge.
|
||||
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
|
||||
return nil
|
||||
}
|
||||
|
||||
// helper: ensure we don't shadow id.UserID import in test harness.
|
||||
@@ -545,11 +751,30 @@ type autopilotWalkResult struct {
|
||||
// combat already auto-resolves inside resolveCombatRoom; elite/boss
|
||||
// doorways stop here so the player can choose !fight on their own terms.
|
||||
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false)
|
||||
// runAutopilotWalk resolves through getActiveExpedition, so a member would
|
||||
// be told they are not on an expedition at all. Same refusal `!zone advance`
|
||||
// gives — this is the same walk, reached by its other name.
|
||||
if isPartyMember(ctx.Sender) {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Your party leader sets the pace. `!map` to see where you're standing.")
|
||||
}
|
||||
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false)
|
||||
if r.initErr != "" {
|
||||
return p.SendDM(ctx.Sender, r.initErr)
|
||||
}
|
||||
return p.streamFlow(ctx.Sender, r.stream, r.finalMsg)
|
||||
// Emergence seam: a natural run-complete (boss down / dead-end node)
|
||||
// surfaces the player alive just like an extract or abandon — roll pet
|
||||
// arrival here too. The roll lives in the real callers, not in
|
||||
// runAutopilotWalk, so the sim path (which calls the walk directly)
|
||||
// never fires arrival DMs. See maybeRollPetArrivalOnEmerge. Defer it
|
||||
// behind the paced stream so the "animal in your house" DM lands after
|
||||
// the "Run complete" beat, not before it.
|
||||
var after func()
|
||||
if r.reason == stopComplete {
|
||||
uid := ctx.Sender
|
||||
after = func() { p.maybeRollPetArrivalOnEmerge(uid) }
|
||||
}
|
||||
return p.streamFlowThen(ctx.Sender, r.stream, r.finalMsg, after)
|
||||
}
|
||||
|
||||
// runAutopilotWalk runs the autopilot loop up to maxRooms times and
|
||||
@@ -558,7 +783,46 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
// run graph / harvest tally / supplies / threat — same as before, just
|
||||
// no streamFlow here. compact==true switches the underlying combat
|
||||
// narration into terse mode and auto-resolves elite (not boss) rooms.
|
||||
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact bool) autopilotWalkResult {
|
||||
// forkAutoPickTimeout — how long a background fork may sit unanswered
|
||||
// before the autopilot picks an available route itself. Short enough that
|
||||
// the expedition keeps moving rather than idling out to the 24h stale-run
|
||||
// reaper; long enough that a player away for the evening still gets first
|
||||
// say on a genuine fork.
|
||||
const forkAutoPickTimeout = 8 * time.Hour
|
||||
|
||||
// autoPickStaleFork commits the first unlocked option of a stale background
|
||||
// fork, advancing the run to that node exactly as `!zone go <n>` would
|
||||
// (advanceZoneRunNode + region-transition hook). Returns false — no pick —
|
||||
// when every option is locked, so the caller re-emits the prompt and the
|
||||
// run idles on toward the reaper. The choice is logged as a narrative entry
|
||||
// so the end-of-day digest can surface the decision the player missed.
|
||||
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
|
||||
var chosen *pendingChoice
|
||||
for i := range pf.Options {
|
||||
if pf.Options[i].Unlocked {
|
||||
chosen = &pf.Options[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
return false // nothing unlocked — leave it for the player / reaper
|
||||
}
|
||||
if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
|
||||
slog.Warn("expedition: auto-pick stale fork",
|
||||
"user", run.UserID, "run", run.RunID, "err", err)
|
||||
return false
|
||||
}
|
||||
g, _ := loadZoneGraph(run.ZoneID)
|
||||
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
|
||||
if exp != nil {
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s",
|
||||
int(forkAutoPickTimeout.Hours()), chosen.Label), "")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
if err != nil {
|
||||
return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()}
|
||||
@@ -570,6 +834,31 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
||||
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
|
||||
}
|
||||
|
||||
// Already standing at a pending fork. The autopilot can't pick for the
|
||||
// player, so a fresh fork re-emits the prompt with rooms=0 (background
|
||||
// DM suppression keeps quiet; the rooms counter doesn't tick on a no-op
|
||||
// walk). But a background fork left unanswered past forkAutoPickTimeout
|
||||
// would otherwise idle all the way to the 24h stale-run reaper and end
|
||||
// the expedition — so once it's stale, auto-pick the first available
|
||||
// (unlocked) route and keep walking instead of stalling out.
|
||||
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
|
||||
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
|
||||
picked := compact &&
|
||||
time.Since(run.LastActionAt) > forkAutoPickTimeout &&
|
||||
p.autoPickStaleFork(exp, run, pf)
|
||||
if !picked {
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
return autopilotWalkResult{
|
||||
finalMsg: renderForkPrompt(zone, *pf),
|
||||
rooms: 0,
|
||||
reason: stopFork,
|
||||
}
|
||||
}
|
||||
// Auto-picked: the run now points at the chosen node. Fall
|
||||
// through into the walk loop so this same tick advances it.
|
||||
}
|
||||
}
|
||||
|
||||
var stream []string
|
||||
var finalMsg string
|
||||
rooms := 0
|
||||
@@ -593,7 +882,7 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
||||
}
|
||||
}
|
||||
|
||||
res, aerr := p.advanceOnceWithOpts(ctx, compact)
|
||||
res, aerr := p.advanceOnceWithOpts(ctx, compact, inlineBossCombat)
|
||||
if aerr != nil {
|
||||
return autopilotWalkResult{initErr: aerr.Error()}
|
||||
}
|
||||
@@ -608,10 +897,52 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
||||
// Doorway/blocked stops fire *before* the current room actually
|
||||
// resolved — those don't count as a walked room. Everything else
|
||||
// (OK, fork after a clear, ended after combat, complete) does.
|
||||
if res.reason != stopBlocked && res.reason != stopElite && res.reason != stopBoss {
|
||||
// stopBossSafety also fires at the doorway (compact autopilot
|
||||
// bailed before engaging), so it doesn't count either.
|
||||
if res.reason != stopBlocked && res.reason != stopElite && res.reason != stopBoss && res.reason != stopBossSafety {
|
||||
rooms++
|
||||
}
|
||||
|
||||
// Multi-region auto-advance: a mid-zone region clear completes the
|
||||
// region's run (stopComplete) but leaves the wrapping expedition
|
||||
// active. Rather than dead-stopping the walk at every region
|
||||
// boundary, cross into the next region — burning the transit day +
|
||||
// supplies exactly like manual `!region travel` — and keep walking
|
||||
// within the remaining room budget. A full zone clear instead flips
|
||||
// the expedition to 'complete' (getActiveExpedition → nil) and falls
|
||||
// through to the normal stop below.
|
||||
if res.reason == stopComplete {
|
||||
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil &&
|
||||
IsMultiRegionZone(fresh.ZoneID) {
|
||||
if cur, ok := CurrentRegion(fresh); ok {
|
||||
if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok {
|
||||
// A region crossing burns a transit day + supplies and
|
||||
// draws unprotected wandering damage. On the background
|
||||
// walk, don't cross while the player is weak — preflight
|
||||
// HP/SU and hand the crossing back to a foreground
|
||||
// `!region travel` / `!expedition run` if either is low.
|
||||
if compact {
|
||||
if msg, stop := autopilotPreflight(ctx.Sender, fresh); stop {
|
||||
finalMsg = res.final + "\n\n" + msg
|
||||
reason = stopPreflight
|
||||
break
|
||||
}
|
||||
}
|
||||
stream = append(stream, res.final)
|
||||
transit, terr := p.advanceToNextRegion(ctx.Sender, fresh, cur, next)
|
||||
if terr != nil {
|
||||
finalMsg = res.final + "\n\n⏸ **Autopilot paused — region transit failed.** `!region travel` to cross over manually."
|
||||
reason = stopComplete
|
||||
break
|
||||
}
|
||||
stream = append(stream, transit)
|
||||
exp = fresh
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if res.reason != stopOK {
|
||||
footer := autopilotFooter(res.reason, rooms)
|
||||
if footer != "" {
|
||||
@@ -632,15 +963,16 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
||||
exp = fresh
|
||||
}
|
||||
|
||||
// Arrived at a Boss doorway: stop here. The "Room X/Y — Boss.
|
||||
// !fight when ready." line in res.final already tells the player
|
||||
// what to do; another loop iteration would just hit the gate and
|
||||
// emit a duplicate "Room X/Y — Boss" message.
|
||||
// Arrived at an Elite/Boss doorway. Foreground stops here so the
|
||||
// player can decide; the "Room X/Y — Boss. !fight when ready."
|
||||
// line in res.final already tells them what to do.
|
||||
//
|
||||
// For Elite + non-compact, do the same. In compact mode we let
|
||||
// the next iteration run because the gate will auto-resolve the
|
||||
// elite inline (which is the whole point of compact mode).
|
||||
if res.nextRoomType == RoomBoss || (res.nextRoomType == RoomElite && !compact) {
|
||||
// In compact mode (background autopilot, long-expedition D2/D3)
|
||||
// we let the next iteration run because the gate will auto-
|
||||
// resolve the encounter inline — elite always, boss when the
|
||||
// safety check passes (otherwise the gate returns stopBossSafety
|
||||
// and the autorun ticker pitches a rest camp).
|
||||
if !compact && (res.nextRoomType == RoomBoss || res.nextRoomType == RoomElite) {
|
||||
r := stopBoss
|
||||
if res.nextRoomType == RoomElite {
|
||||
r = stopElite
|
||||
@@ -749,6 +1081,8 @@ func autopilotFooter(reason stopReason, rooms int) string {
|
||||
return fmt.Sprintf("⏸ **Autopilot paused — elite ahead** (after %s). `!fight` when ready, then `!expedition run` to continue.", roomsStr)
|
||||
case stopBoss:
|
||||
return fmt.Sprintf("⏸ **Autopilot paused — boss ahead** (after %s). `!fight` when ready.", roomsStr)
|
||||
case stopBossSafety:
|
||||
return "" // res.final already carries the held-back-from-boss line
|
||||
case stopEnded:
|
||||
return "" // death narration is the final; no footer
|
||||
case stopComplete:
|
||||
|
||||
@@ -66,6 +66,76 @@ func TestParseSupplyArgs_Combinations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoadout_PresetTokens(t *testing.T) {
|
||||
cases := []struct {
|
||||
tok string
|
||||
tier ZoneTier
|
||||
std, dlx int
|
||||
}{
|
||||
{"lean", ZoneTierBeginner, 1, 0},
|
||||
{"balanced", ZoneTierJourneyman, 3, 0},
|
||||
{"heavy", ZoneTierLegendary, 7, 2},
|
||||
{"h", ZoneTierVeteran, 5, 1},
|
||||
{"L", ZoneTierLegendary, 5, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := resolveLoadoutOrParse(c.tok, c.tier)
|
||||
if err != nil {
|
||||
t.Errorf("%q@T%d: %v", c.tok, c.tier, err)
|
||||
continue
|
||||
}
|
||||
if got.StandardPacks != c.std || got.DeluxePacks != c.dlx {
|
||||
t.Errorf("%q@T%d: got %+v, want std=%d dlx=%d", c.tok, c.tier, got, c.std, c.dlx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoadout_FallsBackToRawParse(t *testing.T) {
|
||||
got, err := resolveLoadoutOrParse("2s 1d", ZoneTierJourneyman)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.StandardPacks != 2 || got.DeluxePacks != 1 {
|
||||
t.Errorf("raw parse: got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadoutPurchase_HeavyMatchesCaps(t *testing.T) {
|
||||
for _, tier := range []ZoneTier{ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman, ZoneTierVeteran, ZoneTierLegendary} {
|
||||
p := loadoutPurchase(tier, LoadoutHeavy)
|
||||
std, dlx := supplyPackCaps(tier)
|
||||
if p.StandardPacks != std || p.DeluxePacks != dlx {
|
||||
t.Errorf("T%d heavy %+v, want std=%d dlx=%d", tier, p, std, dlx)
|
||||
}
|
||||
if err := p.Validate(tier); err != nil {
|
||||
t.Errorf("T%d heavy fails own validation: %v", tier, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpeditionCmd_StartNoArgsPromptsLoadout(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-cmd-prompt:example")
|
||||
expeditionCmdTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
euro.Credit(uid, 500, "test")
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
// Empty pack args: should prompt, NOT start.
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exp, _ := getActiveExpedition(uid); exp != nil {
|
||||
t.Error("expedition started on empty-arg prompt; should have waited for preset choice")
|
||||
}
|
||||
if bal := euro.GetBalance(uid); bal != 500 {
|
||||
t.Errorf("coins moved on prompt: %v", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreatThresholdLabel_Bands(t *testing.T) {
|
||||
cases := []struct {
|
||||
level int
|
||||
@@ -119,8 +189,8 @@ func TestExpeditionCmd_StartAbandonRoundtrip(t *testing.T) {
|
||||
euro.Credit(uid, 200, "test setup")
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
// Default 1 standard pack = 50 coins. Should land an active expedition.
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil {
|
||||
// Lean preset = 1 standard pack at T1 = 50 coins. Should land an active expedition.
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens lean"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, err := getActiveExpedition(uid)
|
||||
@@ -219,7 +289,7 @@ func TestExpeditionCmd_RunWalksRooms(t *testing.T) {
|
||||
euro.Credit(uid, 200, "test setup")
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil {
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens lean"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
@@ -310,6 +380,7 @@ func TestAutopilotFooter_Reasons(t *testing.T) {
|
||||
{stopEnded, "", false},
|
||||
{stopComplete, "", false},
|
||||
{stopBlocked, "", false},
|
||||
{stopBossSafety, "", false}, // res.final carries the held-back line
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := autopilotFooter(c.r, 3)
|
||||
@@ -323,6 +394,59 @@ func TestAutopilotFooter_Reasons(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBossSafetyGate covers all three trip conditions (HP, supplies,
|
||||
// exhaustion) and the all-clear case. The gate is the D3 boss carve-out
|
||||
// replacement — compact autopilot asks it before engaging the boss.
|
||||
func TestBossSafetyGate(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-boss-safety-gate:example")
|
||||
expeditionCmdTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
healthyExp := &Expedition{
|
||||
Supplies: ExpeditionSupplies{Current: 10, DailyBurn: 1},
|
||||
}
|
||||
|
||||
// All-clear baseline: full HP, supplies fat, no exhaustion → no block.
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
c.HPCurrent = c.HPMax
|
||||
c.Exhaustion = 0
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg, blocked := bossSafetyGate(uid, healthyExp); blocked {
|
||||
t.Fatalf("expected healthy party to pass gate, blocked with %q", msg)
|
||||
}
|
||||
|
||||
// HP below 80% → block.
|
||||
c.HPCurrent = int(float64(c.HPMax) * 0.5)
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg, blocked := bossSafetyGate(uid, healthyExp); !blocked || !strings.Contains(msg, "HP") {
|
||||
t.Errorf("HP gate: blocked=%v msg=%q", blocked, msg)
|
||||
}
|
||||
|
||||
// HP healed, but supplies under one day's burn → block.
|
||||
c.HPCurrent = c.HPMax
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lowSU := &Expedition{Supplies: ExpeditionSupplies{Current: 0.5, DailyBurn: 1.5}}
|
||||
if msg, blocked := bossSafetyGate(uid, lowSU); !blocked || !strings.Contains(msg, "supplies") {
|
||||
t.Errorf("SU gate: blocked=%v msg=%q", blocked, msg)
|
||||
}
|
||||
|
||||
// Supplies refilled, but exhaustion ≥ 3 → block.
|
||||
c.Exhaustion = 3
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg, blocked := bossSafetyGate(uid, healthyExp); !blocked || !strings.Contains(msg, "exhaustion") {
|
||||
t.Errorf("exhaustion gate: blocked=%v msg=%q", blocked, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-cmd-nochar:example")
|
||||
|
||||
@@ -146,12 +146,16 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
}
|
||||
|
||||
preCombatHP, _ := dndHPSnapshot(userID)
|
||||
result, err := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood)
|
||||
// P6e: the party fights the interrupt. The surprise nick above stays on the
|
||||
// leader — it is one free swing at whoever is walking point.
|
||||
pres, seated, err := p.runZoneCombatRoster(
|
||||
fightRoster(userID), monster, int(zone.Tier), nil, run.DMMood)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false
|
||||
}
|
||||
result := pres.Seats[0]
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
scanMoodEventsFromCombat(run.RunID, result)
|
||||
scanMoodEventsFromEvents(run.RunID, pres.Events)
|
||||
|
||||
var b strings.Builder
|
||||
if kind == InterruptPatrol {
|
||||
@@ -182,23 +186,34 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
_ = applyThreatDelta(exp.ID, retreatThreatBump, "combat retreat")
|
||||
b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You break off, wounded but alive. (Threat +%d.)",
|
||||
monster.Name, retreatThreatBump))
|
||||
// A party can run out the clock having lost somebody. The retreat is
|
||||
// still a retreat — the run goes on — but the fallen are still fallen.
|
||||
if line := partyCasualtyLine(closeOutZoneLoss(pres, seated, zone, "expedition")); line != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(line)
|
||||
}
|
||||
return b.String(), false
|
||||
}
|
||||
// True death.
|
||||
// True death. The engine only ends a fight the players lost once nobody is
|
||||
// standing, so this branch is the whole roster.
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
_ = abandonZoneRun(userID)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
_, _, _ = forcedExtractExpedition(exp.ID, "interrupt death")
|
||||
markAdventureDead(userID, "expedition", zone.Display)
|
||||
closeOutZoneLoss(pres, seated, zone, "expedition")
|
||||
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(seated) > 1 {
|
||||
b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", monster.Name))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name))
|
||||
}
|
||||
return b.String(), true
|
||||
}
|
||||
|
||||
// Win: kill-log writer.
|
||||
// Win: kill-log writer. Run-scoped once; loot and death fan out per seat.
|
||||
_ = recordZoneKill(exp, monster.ID)
|
||||
if line := flavor.Pick(flavor.CombatVictory); line != "" {
|
||||
b.WriteString(line)
|
||||
@@ -206,10 +221,15 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).",
|
||||
monster.Name, preCombatHP, postHP, maxHP))
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||
drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, elite, "expedition")
|
||||
if drop != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drop)
|
||||
}
|
||||
if line := partyCasualtyLine(downed); line != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(line)
|
||||
}
|
||||
return b.String(), false
|
||||
}
|
||||
|
||||
@@ -471,13 +491,15 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood)
|
||||
pres, seated, rerr := p.runZoneCombatRoster(
|
||||
fightRoster(userID), monster, int(zone.Tier), nil, run.DMMood)
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
return
|
||||
}
|
||||
result := pres.Seats[0]
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
scanMoodEventsFromCombat(run.RunID, result)
|
||||
scanMoodEventsFromEvents(run.RunID, pres.Events)
|
||||
|
||||
// Intro: patrol-encounter flavor + creature stat block.
|
||||
var ib strings.Builder
|
||||
@@ -503,6 +525,11 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
_ = applyThreatDelta(exp.ID, retreatThreatBump, "patrol retreat")
|
||||
ob.WriteString(fmt.Sprintf("⏳ The patrol drags on. You break off, wounded but alive. (Threat +%d.)",
|
||||
retreatThreatBump))
|
||||
// The run continues, but a party may have left somebody behind.
|
||||
if line := partyCasualtyLine(closeOutZoneLoss(pres, seated, zone, "patrol")); line != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(line)
|
||||
}
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
@@ -515,12 +542,16 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
_ = abandonZoneRun(userID)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
_, _, _ = forcedExtractExpedition(exp.ID, "patrol death")
|
||||
markAdventureDead(userID, "patrol", zone.Display)
|
||||
closeOutZoneLoss(pres, seated, zone, "patrol")
|
||||
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
if len(seated) > 1 {
|
||||
ob.WriteString("💀 The patrol takes the party down. Run ended.")
|
||||
} else {
|
||||
ob.WriteString("💀 The patrol takes you down. Run ended.")
|
||||
}
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
@@ -536,10 +567,15 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
}
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||
drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, false, "patrol")
|
||||
if drop != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(drop)
|
||||
}
|
||||
if line := partyCasualtyLine(downed); line != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(line)
|
||||
}
|
||||
outcome = ob.String()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,8 +32,153 @@ import (
|
||||
const (
|
||||
expeditionBriefingHour = 6
|
||||
expeditionRecapHour = 21
|
||||
|
||||
// nightSafetyNet — if an event-anchored expedition's last rollover is
|
||||
// older than this, the 06:00 UTC briefing ticker force-fires
|
||||
// processNightCamp itself. Without this, an expedition whose autopilot
|
||||
// stalled (long combat, missed tick, manual halt) would drift forever
|
||||
// without burning supplies or advancing days. 28h gives the autopilot
|
||||
// 4h of slop past a normal day before we step in.
|
||||
nightSafetyNet = 28 * time.Hour
|
||||
)
|
||||
|
||||
// briefingIdleSkipWindow — D4-b: an event-anchored expedition skips its
|
||||
// 06:00 UTC re-engagement DM when the player's last_activity is older than
|
||||
// the new day's briefing threshold (i.e. they haven't moved since before
|
||||
// the day rolled). The briefing then fires lazily from OnMessage on the
|
||||
// next inbound message via maybeDeliverDeferredBriefing. The safety-net
|
||||
// force-fire path still wins past nightSafetyNet so stalled autopilots
|
||||
// never sit forever waiting on a silent player.
|
||||
|
||||
// eventAnchoredCutoff — expeditions started at or after this timestamp
|
||||
// use the D2-b event-anchored day-rollover model: day++/burn/threat-drift
|
||||
// fire when the autopilot (or a player !camp) pitches a night camp, and
|
||||
// the 06:00 UTC briefing becomes a re-engagement DM with a safety-net
|
||||
// force. Expeditions started before this stay on the legacy UTC-anchored
|
||||
// briefing rollover until they end.
|
||||
var eventAnchoredCutoff = time.Date(2026, 5, 27, 18, 0, 0, 0, time.UTC)
|
||||
|
||||
// isEventAnchored — true when this expedition uses the D2-b model.
|
||||
func isEventAnchored(e *Expedition) bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
return !e.StartDate.Before(eventAnchoredCutoff)
|
||||
}
|
||||
|
||||
// nightRolloverResult — the side effects processNightCamp produced, so
|
||||
// callers (autopilot pitch, manual !camp, safety-net briefing) can render
|
||||
// the same numbers into their own DM block.
|
||||
type nightRolloverResult struct {
|
||||
Burn float32
|
||||
TemporalLines []string
|
||||
MilestoneLines []string
|
||||
Starved bool
|
||||
}
|
||||
|
||||
// nightRolloverBurn — stage 1 of the day rollover: zone temporal pre-burn
|
||||
// + daily supply burn + current_day++. Returns the burn applied. Callers
|
||||
// follow this with applyCampRest (if pitching) and then nightRolloverDrift
|
||||
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
|
||||
// between the two so a fortified camp's −5 lands before drift's +3.
|
||||
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
|
||||
var burn float32
|
||||
// Forage and burn land in one write, so they resolve together against the
|
||||
// pool as it stands now — not as `e` last saw it.
|
||||
newSupplies, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
|
||||
// today's supplies, not tomorrow's. Logged so the end-of-day digest
|
||||
// can surface the gain; pure no-op for non-Ranger characters.
|
||||
if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil {
|
||||
if gain := applyRangerForage(fresh, c, nil); gain > 0 {
|
||||
_ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage",
|
||||
fmt.Sprintf("ranger forage +%g SU", gain),
|
||||
flavor.Pick(flavor.HarvestForageSuccess))
|
||||
}
|
||||
}
|
||||
burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1)
|
||||
if burnOverride.Multiplier > 0 {
|
||||
// The temporal override replaces the harsh/siege multiplier, not the
|
||||
// roster's: a party still eats N × 0.8 rations inside a time-warped zone.
|
||||
burn = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100
|
||||
next := fresh.Supplies
|
||||
next.Current -= burn
|
||||
if next.Current < 0 {
|
||||
next.Current = 0
|
||||
}
|
||||
next.ForagedToday = false
|
||||
return next, nil
|
||||
}
|
||||
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||||
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||||
burn = b
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := advanceExpeditionDay(e.ID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
e.Supplies = newSupplies
|
||||
e.CurrentDay++
|
||||
return burn, nil
|
||||
}
|
||||
|
||||
// nightRolloverDrift — stage 2: daily threat drift, zone temporal post-
|
||||
// rollover, starvation check, max-threat record, milestones. Stamps
|
||||
// last_briefing_at = now so the UTC briefing ticker treats today as
|
||||
// already-rolled. `now` is the wallclock to stamp; callers that already
|
||||
// did the stamp via a CAS (deliverBriefing) pass time.Time{} to skip.
|
||||
func (p *AdventurePlugin) nightRolloverDrift(e *Expedition, now time.Time) nightRolloverResult {
|
||||
var out nightRolloverResult
|
||||
if _, _, err := applyDailyThreatDrift(e); err != nil {
|
||||
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
|
||||
}
|
||||
out.TemporalLines = applyZoneTemporalPostRollover(e)
|
||||
|
||||
if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive {
|
||||
_, _, _ = forcedExtractExpedition(e.ID, "starvation")
|
||||
e.Status = ExpeditionStatusAbandoned
|
||||
line := flavor.Pick(flavor.ExtractionForced)
|
||||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative",
|
||||
"forced extraction: starvation", line)
|
||||
out.Starved = true
|
||||
}
|
||||
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
|
||||
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
|
||||
if tax > 0 {
|
||||
p.euro.Debit(id.UserID(e.UserID), float64(tax),
|
||||
"forced extraction tax")
|
||||
}
|
||||
}
|
||||
_ = recordMaxThreat(e)
|
||||
out.MilestoneLines = p.checkDailyMilestones(e)
|
||||
|
||||
if !now.IsZero() {
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_briefing_at = ?
|
||||
WHERE expedition_id = ?`, now, e.ID); err == nil {
|
||||
e.LastBriefingAt = &now
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// processNightCamp — burn + drift in one go, no rest in between. Used by
|
||||
// callers (autopilot pitch, manual !camp, event-anchored safety net) that
|
||||
// either apply their own rest separately or don't apply one at all.
|
||||
func (p *AdventurePlugin) processNightCamp(e *Expedition) (nightRolloverResult, error) {
|
||||
burn, err := p.nightRolloverBurn(e)
|
||||
if err != nil {
|
||||
return nightRolloverResult{}, err
|
||||
}
|
||||
out := p.nightRolloverDrift(e, time.Now().UTC())
|
||||
out.Burn = burn
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// expeditionBriefingTicker — 06:00 UTC daily briefing.
|
||||
func (p *AdventurePlugin) expeditionBriefingTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
@@ -91,6 +236,24 @@ func (p *AdventurePlugin) fireExpeditionBriefings(now time.Time) {
|
||||
slog.Info("expedition: briefing deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
|
||||
continue
|
||||
}
|
||||
// D4-b: skip the ticker DM for event-anchored expeditions whose
|
||||
// player has been idle past the new day's threshold. The safety-
|
||||
// net force path (handled inside deliverBriefingEventAnchored)
|
||||
// still has to run when the autopilot stalled past nightSafetyNet,
|
||||
// so only skip when both the player is idle AND we're not in the
|
||||
// safety-net window.
|
||||
if isEventAnchored(e) && e.LastActivity.Before(threshold) {
|
||||
var since time.Duration
|
||||
if e.LastBriefingAt != nil {
|
||||
since = now.Sub(*e.LastBriefingAt)
|
||||
} else {
|
||||
since = now.Sub(e.StartDate)
|
||||
}
|
||||
if since <= nightSafetyNet {
|
||||
slog.Info("expedition: briefing deferred — player idle, awaiting next inbound", "expedition", e.ID, "user", e.UserID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := p.deliverBriefing(e, now); err != nil {
|
||||
slog.Error("expedition: briefing", "expedition", e.ID, "err", err)
|
||||
}
|
||||
@@ -176,14 +339,17 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
||||
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
||||
// deliverBriefing posts the morning briefing DM. For legacy UTC-anchored
|
||||
// expeditions it also drives the day rollover (supply burn, day++, threat
|
||||
// drift) via processNightCamp. For event-anchored expeditions (D2-b) the
|
||||
// rollover is owned by the autopilot's night-camp pitch; the briefing
|
||||
// ticker only re-engages the player and force-fires the rollover after a
|
||||
// safety-net window.
|
||||
//
|
||||
// Idempotency: the first thing we do is an atomic compare-and-set on
|
||||
// last_briefing_at. If another invocation (clock skew, restart, double
|
||||
// fire) already claimed today's rollover, rowsAffected == 0 and we bail
|
||||
// without re-applying supply burn / day++ / threat drift.
|
||||
// Idempotency: atomic compare-and-set on last_briefing_at gates the body.
|
||||
// A double-fire on the same expedition is a no-op.
|
||||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
priorBriefing := e.LastBriefingAt
|
||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||
res, err := db.Get().Exec(`
|
||||
@@ -200,45 +366,24 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil // already delivered for this day
|
||||
}
|
||||
e.LastBriefingAt = &now
|
||||
|
||||
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
|
||||
// can override the entire burn calculation with a fixed multiplier
|
||||
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
|
||||
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
|
||||
// D2-b: event-anchored expeditions own the day rollover via the
|
||||
// autopilot night camp. The 06:00 ticker either posts a re-engagement
|
||||
// DM (rollover happened recently) or force-fires processNightCamp
|
||||
// itself (safety net for stalled autopilots).
|
||||
if isEventAnchored(e) {
|
||||
return p.deliverBriefingEventAnchored(e, priorBriefing)
|
||||
}
|
||||
|
||||
// Advance day + supply burn happen together at the morning rollover.
|
||||
var newSupplies ExpeditionSupplies
|
||||
var burn float32
|
||||
if burnOverride.Multiplier > 0 {
|
||||
// Phase 5-B: temporal overrides bypass applyDailyBurn, so apply
|
||||
// the same global burn-rate multiplier explicitly here. Without
|
||||
// this, tidal/unraveling days would burn at pre-Phase-5-B rates
|
||||
// while normal days burn at half — an inconsistency the user
|
||||
// would feel as "tidal days are now disproportionately harsh."
|
||||
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100
|
||||
newSupplies = e.Supplies
|
||||
newSupplies.Current -= burn
|
||||
if newSupplies.Current < 0 {
|
||||
newSupplies.Current = 0
|
||||
}
|
||||
newSupplies.ForagedToday = false
|
||||
} else {
|
||||
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
|
||||
newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
|
||||
}
|
||||
if err := updateSupplies(e.ID, newSupplies); err != nil {
|
||||
burn, err := p.nightRolloverBurn(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := advanceExpeditionDay(e.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Supplies = newSupplies
|
||||
e.CurrentDay++
|
||||
|
||||
// E2d: overnight camp rest effects (HP/spells/threat). Auto-breaks
|
||||
// the camp after applying. Run before threat drift so a fortified
|
||||
// camp's −5 lands first and a same-day +3 drift can't push back over
|
||||
// a threshold the rest just dropped.
|
||||
// E2d: overnight camp rest. Runs after burn/day++ but before drift so
|
||||
// a fortified camp's −5 lands first and a same-day +3 drift can't push
|
||||
// back over a threshold the rest just dropped.
|
||||
restSummary := processOvernightCamp(e)
|
||||
if restSummary != "" {
|
||||
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
||||
@@ -248,60 +393,32 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
}
|
||||
}
|
||||
|
||||
// E2a: daily threat drift (+3 base, GM-mood modifier). No-op after
|
||||
// boss kill. May cross a threshold and append a flavor-bearing log.
|
||||
if _, _, err := applyDailyThreatDrift(e); err != nil {
|
||||
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
|
||||
}
|
||||
|
||||
// E3: zone temporal events post-rollover narration (after the day
|
||||
// has advanced, so e.CurrentDay reflects the new day).
|
||||
temporalLines := applyZoneTemporalPostRollover(e)
|
||||
|
||||
// §4.3: starvation triggers forced extraction. With no CON tracking
|
||||
// in this layer, the briefing-time check is the practical equivalent
|
||||
// of "CON reaches 0" — a starvation morning means the player can't
|
||||
// reasonably press on. Apply the §10.2 coin tax and flip status.
|
||||
if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive {
|
||||
_, _, _ = forcedExtractExpedition(e.ID, "starvation")
|
||||
e.Status = ExpeditionStatusAbandoned
|
||||
line := flavor.Pick(flavor.ExtractionForced)
|
||||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative",
|
||||
"forced extraction: starvation", line)
|
||||
}
|
||||
|
||||
// E5b: if a temporal event (or starvation above) forced extraction,
|
||||
// apply the §10.2 coin tax. The temporal layer flips the row to
|
||||
// 'abandoned'; the cycle holds the euro handle to do the debit.
|
||||
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
|
||||
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
|
||||
if tax > 0 {
|
||||
p.euro.Debit(id.UserID(e.UserID), float64(tax),
|
||||
"forced extraction tax")
|
||||
}
|
||||
}
|
||||
|
||||
// E6b: sample today's threat into RegionState["max_threat_seen"] before
|
||||
// any milestone check reads it; then award daily milestones reached by
|
||||
// the new day count (First Night day 2, Week One day 8, Two Weeks day 15).
|
||||
_ = recordMaxThreat(e)
|
||||
milestoneLines := p.checkDailyMilestones(e)
|
||||
// Pass time.Time{} — the CAS at the top of deliverBriefing already
|
||||
// stamped last_briefing_at with the synthetic now; don't overwrite it.
|
||||
roll := p.nightRolloverDrift(e, time.Time{})
|
||||
roll.Burn = burn
|
||||
|
||||
line := pickMorningBriefing(e.CurrentDay)
|
||||
body := renderMorningBriefing(e, line, burn)
|
||||
if restSummary != "" {
|
||||
body += "\n💤 _" + restSummary + "_\n"
|
||||
}
|
||||
for _, tl := range temporalLines {
|
||||
for _, tl := range roll.TemporalLines {
|
||||
body += "\n🌀 " + tl + "\n"
|
||||
}
|
||||
for _, ml := range milestoneLines {
|
||||
for _, ml := range roll.MilestoneLines {
|
||||
body += "\n" + ml
|
||||
}
|
||||
|
||||
if uid := id.UserID(e.UserID); uid != "" {
|
||||
if err := p.SendDM(uid, body); err != nil {
|
||||
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
|
||||
p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
|
||||
// Emergence seam: a briefing-time forced extraction (starvation / abyss
|
||||
// collapse) surfaces the players alive — roll pet arrival. Combat/patrol
|
||||
// deaths never reach deliverBriefing (the row is already abandoned), so an
|
||||
// abandoned status here means a survived emergence; those death paths roll
|
||||
// on respawn instead. Every member emerges, so every member rolls.
|
||||
if e.Status == ExpeditionStatusAbandoned {
|
||||
for _, uid := range expeditionAudience(e) {
|
||||
p.maybeRollPetArrivalOnEmerge(uid)
|
||||
}
|
||||
}
|
||||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
|
||||
@@ -311,6 +428,117 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeDeliverDeferredBriefing — D4-b lazy-delivery hook. When the 06:00
|
||||
// UTC ticker skips an event-anchored expedition because the player was
|
||||
// idle, the morning DM is posted here on their next inbound message.
|
||||
// Cheap fast paths (no expedition, not event-anchored, briefing already
|
||||
// stamped past today's threshold) keep the per-message cost to one
|
||||
// indexed row lookup. Idempotency rides on deliverBriefing's CAS.
|
||||
func (p *AdventurePlugin) maybeDeliverDeferredBriefing(uid id.UserID, now time.Time) {
|
||||
if uid == "" {
|
||||
return
|
||||
}
|
||||
exp, err := getActiveExpedition(uid)
|
||||
if err != nil || exp == nil || !isEventAnchored(exp) {
|
||||
return
|
||||
}
|
||||
// Stamp presence: per-D4-b, any inbound message in any room counts as
|
||||
// "the player is here." The ticker's idle-skip reads last_activity to
|
||||
// decide whether to suppress the 06:00 DM, so we update it on every
|
||||
// message — not just bot commands.
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET last_activity = ? WHERE expedition_id = ?`,
|
||||
now, exp.ID); err != nil {
|
||||
slog.Warn("expedition: stamp activity", "user", uid, "err", err)
|
||||
}
|
||||
// Only lazy-deliver when a briefing is actually owed: a previous
|
||||
// briefing exists (so we know the cadence is live) or the autopilot
|
||||
// has rolled past day 1 without one (so a rollover happened in the
|
||||
// player's absence). Day-1 inbounds shouldn't trigger a briefing
|
||||
// before the first night camp has even happened.
|
||||
if exp.LastBriefingAt == nil && exp.CurrentDay <= 1 {
|
||||
return
|
||||
}
|
||||
// Don't lazy-deliver before today's 06:00 UTC threshold. The
|
||||
// deliverBriefing CAS keys off the same threshold, so a pre-06:00
|
||||
// fire would double-emit when the 06:00 ticker sweep arrives.
|
||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||
if now.Before(threshold) {
|
||||
return
|
||||
}
|
||||
if exp.LastBriefingAt != nil && !exp.LastBriefingAt.Before(threshold) {
|
||||
return
|
||||
}
|
||||
if hasActiveCombatSession(uid) {
|
||||
return
|
||||
}
|
||||
if err := p.deliverBriefing(exp, now); err != nil {
|
||||
slog.Warn("expedition: deferred briefing", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverBriefingEventAnchored — D2-b 06:00 UTC ticker for event-anchored
|
||||
// expeditions. The autopilot's night-camp pitch owns day++/burn/threat-
|
||||
// drift; the ticker just re-engages the player. If the autopilot has
|
||||
// stalled past nightSafetyNet we force-fire processNightCamp ourselves so
|
||||
// the expedition doesn't sit frozen.
|
||||
//
|
||||
// priorBriefing is the last_briefing_at value as of entry into deliverBriefing
|
||||
// (before the CAS clobbered it). nil means day-1 or genuinely never rolled.
|
||||
func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time) error {
|
||||
now := time.Now().UTC()
|
||||
var since time.Duration
|
||||
if priorBriefing != nil {
|
||||
since = now.Sub(*priorBriefing)
|
||||
} else {
|
||||
since = now.Sub(e.StartDate)
|
||||
}
|
||||
forced := since > nightSafetyNet
|
||||
|
||||
var (
|
||||
burn float32
|
||||
temporalLines []string
|
||||
mileLines []string
|
||||
)
|
||||
if forced {
|
||||
roll, err := p.processNightCamp(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
burn = roll.Burn
|
||||
temporalLines = roll.TemporalLines
|
||||
mileLines = roll.MilestoneLines
|
||||
}
|
||||
|
||||
line := pickMorningBriefing(e.CurrentDay)
|
||||
body := renderMorningBriefing(e, line, burn)
|
||||
if forced {
|
||||
body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n"
|
||||
}
|
||||
for _, tl := range temporalLines {
|
||||
body += "\n🌀 " + tl + "\n"
|
||||
}
|
||||
for _, ml := range mileLines {
|
||||
body += "\n" + ml
|
||||
}
|
||||
|
||||
p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
|
||||
if forced && e.Status == ExpeditionStatusAbandoned {
|
||||
for _, uid := range expeditionAudience(e) {
|
||||
p.maybeRollPetArrivalOnEmerge(uid)
|
||||
}
|
||||
}
|
||||
summary := "morning re-engagement"
|
||||
if forced {
|
||||
summary = fmt.Sprintf("safety-net rollover — %.1f SU consumed overnight", burn)
|
||||
}
|
||||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing", summary, line); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverRecap posts the evening recap DM, appends a log entry, and stamps
|
||||
// last_recap_at. No supply burn here — that's the briefing's job.
|
||||
// Idempotency: same pattern as deliverBriefing — claim last_recap_at first.
|
||||
@@ -372,11 +600,7 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
||||
body += "\n" + renderNightCheck(*night)
|
||||
}
|
||||
|
||||
if uid := id.UserID(e.UserID); uid != "" {
|
||||
if err := p.SendDM(uid, body); err != nil {
|
||||
slog.Warn("expedition: send recap DM", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
p.fanOutExpeditionDM(e, body, nil)
|
||||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
|
||||
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
|
||||
return err
|
||||
@@ -384,6 +608,31 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// briefingPetPrefix folds the reader's own 25% morning pet event onto the front
|
||||
// of a briefing and grants the resulting one-day defense buff (reset at midnight
|
||||
// by resetAllPetMorningDefense). The legacy overworld morning DM is skipped
|
||||
// while underground, so this is where that roll lives.
|
||||
//
|
||||
// It is per-reader rather than per-expedition: each member of a party keeps
|
||||
// their own pet, and the buff lands on their own character sheet.
|
||||
func (p *AdventurePlugin) briefingPetPrefix(uid id.UserID, body string) string {
|
||||
pet, err := loadPetState(uid)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
petEvent := petMorningEvent(pet)
|
||||
if petEvent == "" {
|
||||
return body
|
||||
}
|
||||
if char, cerr := loadAdvCharacter(uid); cerr == nil {
|
||||
char.PetMorningDefense = true
|
||||
if serr := saveAdvCharacter(char); serr != nil {
|
||||
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
|
||||
}
|
||||
|
||||
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
|
||||
// 14, 21 each have their own pool; everything else uses the generic pool.
|
||||
func pickMorningBriefing(day int) string {
|
||||
|
||||
@@ -14,6 +14,20 @@ import (
|
||||
// synthetic "now" and verify state transitions. Ticker scheduling is a
|
||||
// thin wrapper over those.
|
||||
|
||||
// rewindToLegacyAnchor backdates an expedition's start_date to before
|
||||
// eventAnchoredCutoff so deliverBriefing exercises the legacy UTC-anchored
|
||||
// mutator path. Tests of the D2-b event-anchored path should NOT call this.
|
||||
func rewindToLegacyAnchor(t *testing.T, exp *Expedition) {
|
||||
t.Helper()
|
||||
before := eventAnchoredCutoff.Add(-24 * time.Hour)
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`,
|
||||
before, exp.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp.StartDate = before
|
||||
}
|
||||
|
||||
func TestPickMorningBriefing_DayBands(t *testing.T) {
|
||||
cases := []struct {
|
||||
day int
|
||||
@@ -57,6 +71,7 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
p := &AdventurePlugin{}
|
||||
now := time.Now().UTC()
|
||||
if err := p.deliverBriefing(exp, now); err != nil {
|
||||
@@ -87,6 +102,174 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// useEventAnchored fences the eventAnchoredCutoff to before the given
|
||||
// expedition's start_date so the D2-b path is taken. Test-scoped via t.Cleanup.
|
||||
func useEventAnchored(t *testing.T, exp *Expedition) {
|
||||
t.Helper()
|
||||
saved := eventAnchoredCutoff
|
||||
eventAnchoredCutoff = exp.StartDate.Add(-time.Minute)
|
||||
t.Cleanup(func() { eventAnchoredCutoff = saved })
|
||||
}
|
||||
|
||||
func TestDeliverBriefing_EventAnchoredSkipsMutators(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-evt-skip:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
// last_briefing_at is NULL and start_date is "now-ish", so the safety
|
||||
// net should NOT fire — sub-28h since start.
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got.CurrentDay != 1 {
|
||||
t.Errorf("day = %d, want 1 (event-anchored briefing should not advance day)", got.CurrentDay)
|
||||
}
|
||||
if got.Supplies.Current != 10 {
|
||||
t.Errorf("supplies = %v, want 10 (event-anchored briefing should not burn)", got.Supplies.Current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverBriefing_EventAnchoredSafetyNetForces(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-evt-safety:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
// Backdate start_date so > nightSafetyNet has elapsed with no rollover.
|
||||
before := time.Now().UTC().Add(-(nightSafetyNet + time.Hour))
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`,
|
||||
before, exp.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp.StartDate = before
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got.CurrentDay != 2 {
|
||||
t.Errorf("day = %d, want 2 (safety net should force rollover)", got.CurrentDay)
|
||||
}
|
||||
if got.Supplies.Current >= 10 {
|
||||
t.Errorf("supplies = %v, want < 10 (safety net should burn)", got.Supplies.Current)
|
||||
}
|
||||
}
|
||||
|
||||
// D4-b: the 06:00 ticker skips event-anchored expeditions whose player
|
||||
// hasn't moved since before today's threshold; the briefing lands lazily
|
||||
// on the next inbound message via maybeDeliverDeferredBriefing.
|
||||
func TestFireBriefings_EventAnchoredIdleSkip(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-d4b-idle:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
// Synthetic now pinned past today's 06:00 UTC so the test outcome is
|
||||
// independent of wall-clock time of day.
|
||||
wall := time.Now().UTC()
|
||||
// Synthetic now is today's 06:30 UTC — just past the ticker threshold,
|
||||
// but well inside the 28h nightSafetyNet window so the safety-net
|
||||
// force path doesn't kick in.
|
||||
now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC)
|
||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||
idleActivity := threshold.Add(-2 * time.Hour)
|
||||
priorBriefing := now.Add(-24 * time.Hour)
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET current_day = 3 WHERE expedition_id = ?`,
|
||||
exp.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
|
||||
idleActivity, priorBriefing, exp.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.fireExpeditionBriefings(now)
|
||||
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got.LastBriefingAt == nil || !got.LastBriefingAt.Equal(priorBriefing) {
|
||||
t.Fatalf("idle ticker should not have stamped last_briefing_at: got %v want %v",
|
||||
got.LastBriefingAt, priorBriefing)
|
||||
}
|
||||
|
||||
// Simulate inbound message: lazy delivery should fire now.
|
||||
p.maybeDeliverDeferredBriefing(uid, now)
|
||||
got, _ = getExpedition(exp.ID)
|
||||
if got.LastBriefingAt == nil || got.LastBriefingAt.Equal(priorBriefing) {
|
||||
t.Fatalf("deferred delivery should have stamped a fresh last_briefing_at: got %v",
|
||||
got.LastBriefingAt)
|
||||
}
|
||||
}
|
||||
|
||||
// D4-b: an active player (last_activity >= today's threshold) still gets
|
||||
// the morning DM on the regular ticker — no idle skip.
|
||||
func TestFireBriefings_EventAnchoredActivePlayerDelivers(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-d4b-active:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wall := time.Now().UTC()
|
||||
now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC)
|
||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||
activeActivity := threshold.Add(15 * time.Minute)
|
||||
priorBriefing := now.Add(-24 * time.Hour)
|
||||
// Pin start_date before today's threshold. Left at the default (real
|
||||
// time.Now()), a suite run after 06:00 UTC lands start_date past the
|
||||
// threshold and loadExpeditionsNeedingBriefing (start_date < threshold)
|
||||
// filters the row out — a wall-clock-of-day flake. useEventAnchored runs
|
||||
// after, so its cutoff tracks the backdated start and the run stays
|
||||
// event-anchored.
|
||||
startAt := now.Add(-24 * time.Hour)
|
||||
exp.StartDate = startAt
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET start_date = ?, last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
|
||||
startAt, activeActivity, priorBriefing, exp.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.fireExpeditionBriefings(now)
|
||||
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got.LastBriefingAt == nil || got.LastBriefingAt.Equal(priorBriefing) {
|
||||
t.Fatalf("active player should have received briefing: last_briefing_at=%v",
|
||||
got.LastBriefingAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-cycle-harsh:example")
|
||||
@@ -97,6 +280,7 @@ func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
// Force harsh conditions via threat clock above 60.
|
||||
if err := applyThreatDelta(exp.ID, 70, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
const (
|
||||
extractResumeWindow = 7 * 24 * time.Hour
|
||||
forcedExtractCoinTaxFrac = 0.20
|
||||
extractionSweepInterval = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -49,7 +50,7 @@ func voluntaryExtractExpedition(userID id.UserID) (*Expedition, error) {
|
||||
return nil, ErrNoActiveExpedition
|
||||
}
|
||||
harsh := e.ThreatLevel > 60
|
||||
newSupplies, _ := applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
|
||||
newSupplies, _ := applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
|
||||
e.Supplies = newSupplies
|
||||
supJSON, _ := json.Marshal(newSupplies)
|
||||
if _, err := db.Get().Exec(`
|
||||
@@ -90,6 +91,7 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
|
||||
}
|
||||
e.Status = ExpeditionStatusAbandoned
|
||||
_ = retireAllRegionRuns(e)
|
||||
releaseParty(e.ID)
|
||||
return e, tax, nil
|
||||
}
|
||||
|
||||
@@ -110,6 +112,90 @@ func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
||||
// no outgoing edges) into expedition completion — the success-path twin of
|
||||
// forceExtractExpeditionForRunLoss. When the cleared run is the active
|
||||
// expedition's current run AND the clear finishes the whole zone (a
|
||||
// single-region zone, or the zone-boss region of a multi-region zone), it
|
||||
// flips the expedition to 'complete', records boss_defeated, and awards
|
||||
// completion milestones. Returns the rendered milestone lines for the caller
|
||||
// to append to the run-complete message.
|
||||
//
|
||||
// No-op (nil) for standalone runs and for mid-zone region clears, which
|
||||
// leave the expedition active so inter-region travel can continue. Without
|
||||
// this, a cleared zone leaves the expedition 'active' forever and the
|
||||
// ambient ticker keeps DMing about a dungeon the player already finished.
|
||||
func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID string) []string {
|
||||
exp, err := getActiveExpedition(userID)
|
||||
if err != nil || exp == nil {
|
||||
return nil
|
||||
}
|
||||
if exp.RunID != runID {
|
||||
return nil // the completed run isn't this expedition's current run
|
||||
}
|
||||
|
||||
if IsMultiRegionZone(exp.ZoneID) {
|
||||
region, ok := CurrentRegion(exp)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, err := MarkRegionBossDefeated(exp, region.ID); err != nil {
|
||||
slog.Warn("expedition: mark region boss defeated",
|
||||
"user", userID, "expedition", exp.ID, "region", region.ID, "err", err)
|
||||
}
|
||||
if !region.IsZoneBoss {
|
||||
return nil // region cleared; expedition continues to the next region
|
||||
}
|
||||
} else {
|
||||
// Single-region zone: there's no region registry to flip through
|
||||
// MarkRegionBossDefeated, so set the zone-level flag directly.
|
||||
exp.BossDefeated = true
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET boss_defeated = 1,
|
||||
last_activity = CURRENT_TIMESTAMP
|
||||
WHERE expedition_id = ?`, exp.ID); err != nil {
|
||||
slog.Warn("expedition: set boss defeated on zone clear",
|
||||
"user", userID, "expedition", exp.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// completeExpedition must run before AwardCompletionMilestones — the
|
||||
// latter gates on status == 'complete'.
|
||||
if err := completeExpedition(exp.ID, ExpeditionStatusComplete); err != nil {
|
||||
slog.Warn("expedition: complete on zone clear",
|
||||
"user", userID, "expedition", exp.ID, "err", err)
|
||||
return nil
|
||||
}
|
||||
exp.Status = ExpeditionStatusComplete
|
||||
_ = retireAllRegionRuns(exp)
|
||||
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
|
||||
return p.AwardCompletionMilestones(exp, false)
|
||||
}
|
||||
|
||||
// midZoneRegionClear reports whether the just-completed run (runID) is the
|
||||
// active expedition's current run AND finishes a non-boss region of a
|
||||
// multi-region zone — i.e. a region clear that leaves the expedition active
|
||||
// with a next region to cross into. Returns the cleared region, the next
|
||||
// region, and true in that case; zero-values + false for a full zone clear,
|
||||
// a standalone run, or any read error. Used to word the run-complete message
|
||||
// (region-cleared vs zone-cleared) without re-deriving region state inline.
|
||||
func midZoneRegionClear(userID id.UserID, runID string) (cur, next ExpeditionRegion, ok bool) {
|
||||
exp, err := getActiveExpedition(userID)
|
||||
if err != nil || exp == nil || exp.RunID != runID || !IsMultiRegionZone(exp.ZoneID) {
|
||||
return ExpeditionRegion{}, ExpeditionRegion{}, false
|
||||
}
|
||||
region, found := CurrentRegion(exp)
|
||||
if !found || region.IsZoneBoss {
|
||||
return ExpeditionRegion{}, ExpeditionRegion{}, false
|
||||
}
|
||||
nxt, found := nextRegion(exp.ZoneID, region.ID)
|
||||
if !found {
|
||||
return ExpeditionRegion{}, ExpeditionRegion{}, false
|
||||
}
|
||||
return region, nxt, true
|
||||
}
|
||||
|
||||
// getResumableExpedition returns the most recent 'extracting' row for the
|
||||
// user, regardless of age (caller checks the 7-day window).
|
||||
func getResumableExpedition(userID id.UserID) (*Expedition, error) {
|
||||
@@ -133,6 +219,94 @@ func getResumableExpedition(userID id.UserID) (*Expedition, error) {
|
||||
return e, err
|
||||
}
|
||||
|
||||
// extractionLapsed reports whether an 'extracting' row is past its resume
|
||||
// window and should be reaped. A NULL completed_at counts as lapsed: the column
|
||||
// is the only clock the window has, and a row without one can never expire.
|
||||
func extractionLapsed(e *Expedition, now time.Time) bool {
|
||||
return e.CompletedAt == nil || now.Sub(*e.CompletedAt) > extractResumeWindow
|
||||
}
|
||||
|
||||
// loadLapsedExtractions returns every extracted expedition whose resume window
|
||||
// has closed, regardless of owner.
|
||||
func loadLapsedExtractions(now time.Time) ([]*Expedition, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT`+expeditionSelectCols+`
|
||||
FROM dnd_expedition e
|
||||
WHERE e.status = 'extracting'
|
||||
AND (e.completed_at IS NULL OR e.completed_at < ?)`,
|
||||
now.Add(-extractResumeWindow))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanExpeditionRows(rows)
|
||||
}
|
||||
|
||||
// sweepLapsedExtractions closes out every extraction whose seven-day window has
|
||||
// run out, which releases its roster.
|
||||
//
|
||||
// Without this the window is a promise the code never keeps. `handleResumeCmd`
|
||||
// expires a lapsed row lazily, but only the *leader* can call `!resume` — so a
|
||||
// leader who quits, forgets, or simply starts a different expedition leaves the
|
||||
// row 'extracting' forever, and releaseParty (which fires only on a terminal
|
||||
// status) never runs. Every member stays seated, and assertNotAdventuring keeps
|
||||
// refusing them a run of their own. `!expedition leave` is their escape, but a
|
||||
// player should not have to find it.
|
||||
func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) {
|
||||
exps, err := loadLapsedExtractions(now)
|
||||
if err != nil {
|
||||
slog.Error("expedition: load lapsed extractions", "err", err)
|
||||
return
|
||||
}
|
||||
for _, e := range exps {
|
||||
if err := p.reapLapsedExtraction(e); err != nil {
|
||||
slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reapLapsedExtraction closes one lapsed extraction and tells its roster the
|
||||
// way back has shut. It is the single reap-with-notify path: the hourly
|
||||
// sweeper loops it, and expeditionCmdStart calls it when a player starts a new
|
||||
// expedition on top of one whose window has already closed — so a member is
|
||||
// never silently unseated by whichever path happens to reach the row first.
|
||||
//
|
||||
// The audience is read before the close-out: completeExpedition disbands the
|
||||
// roster, and expeditionAudience reads that roster.
|
||||
func (p *AdventurePlugin) reapLapsedExtraction(e *Expedition) error {
|
||||
audience := expeditionAudience(e)
|
||||
if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil {
|
||||
return err
|
||||
}
|
||||
zone, _ := getZone(e.ZoneID)
|
||||
body := fmt.Sprintf(
|
||||
"🕯 **The way back has closed — %s**\n\nYour extracted expedition sat past its seven-day resume window, and the dungeon reshaped without you. Day %d is where it ends.\n\nThe loot, XP, and coins you carried out are still yours. `!expedition list` when you're ready for the next one.",
|
||||
zone.Display, e.CurrentDay)
|
||||
for _, uid := range audience {
|
||||
if err := p.SendDM(uid, body); err != nil {
|
||||
slog.Warn("expedition: lapsed-extraction DM failed", "user", uid, "expedition", e.ID, "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expeditionExtractionSweepTicker reaps lapsed extractions hourly. The window is
|
||||
// seven days, so the cadence only bounds how long a freed member waits to hear
|
||||
// about it.
|
||||
func (p *AdventurePlugin) expeditionExtractionSweepTicker() {
|
||||
ticker := time.NewTicker(extractionSweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.sweepLapsedExtractions(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resumeExpedition flips an extracting row back to 'active' with fresh
|
||||
// supplies. Threat/temporal/region state are preserved as-is.
|
||||
func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
|
||||
@@ -154,13 +328,18 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition to extract from.")
|
||||
}
|
||||
if !isLeader {
|
||||
// Extraction ends the expedition for the whole roster, so it is the
|
||||
// leader's call — the same reasoning that makes `!flee` leader-only.
|
||||
return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.")
|
||||
}
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
updated, err := voluntaryExtractExpedition(ctx.Sender)
|
||||
if err != nil {
|
||||
@@ -169,6 +348,7 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
||||
line := flavor.Pick(flavor.ExtractionVoluntary)
|
||||
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
|
||||
"voluntary extraction", line)
|
||||
markActedToday(ctx.Sender)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
|
||||
@@ -177,9 +357,25 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.",
|
||||
(time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")))
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
|
||||
// The extraction ends the day for everyone standing in the dungeon, and the
|
||||
// roster outlives a voluntary extract — `extracting` is a resumable limbo, so
|
||||
// members are still seated and must hear about it. Only the leader can call
|
||||
// `!resume`, so the closing line differs per reader.
|
||||
expires := (time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")
|
||||
p.fanOutExpeditionDM(updated, b.String(), func(uid id.UserID, body string) string {
|
||||
if uid == id.UserID(updated.UserID) {
|
||||
return body + fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.", expires)
|
||||
}
|
||||
return body + fmt.Sprintf("Loot, XP, and coins are kept. Your leader can `!resume` within 7 days, and you'll walk back in with them. After %s the expedition expires.", expires)
|
||||
})
|
||||
|
||||
// Emergence seam: surfacing from a run is when an animal may have moved
|
||||
// into the empty house. Every member surfaced, so every member rolls.
|
||||
for _, uid := range expeditionAudience(updated) {
|
||||
p.maybeRollPetArrivalOnEmerge(uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── !resume command ─────────────────────────────────────────────────────────
|
||||
@@ -197,8 +393,13 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
|
||||
return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` first.")
|
||||
}
|
||||
|
||||
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
|
||||
if existing, isLeader, _ := activeExpeditionFor(ctx.Sender); existing != nil {
|
||||
zone, _ := getZone(existing.ZoneID)
|
||||
if !isLeader {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
@@ -211,18 +412,24 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
|
||||
}
|
||||
if exp.CompletedAt == nil || time.Since(*exp.CompletedAt) > extractResumeWindow {
|
||||
// Expire it so it doesn't keep resurfacing.
|
||||
if extractionLapsed(exp, time.Now().UTC()) {
|
||||
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
||||
// here on its own; this keeps the refusal and the reap in one breath.
|
||||
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
||||
return p.SendDM(ctx.Sender,
|
||||
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
||||
}
|
||||
|
||||
purchase, err := parseSupplyArgs(strings.TrimSpace(args))
|
||||
resumeZone, _ := getZone(exp.ZoneID)
|
||||
// D5-b: prompt for a preset loadout on empty args.
|
||||
if strings.TrimSpace(args) == "" {
|
||||
return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume"))
|
||||
}
|
||||
purchase, err := resolveLoadoutOrParse(strings.TrimSpace(args), resumeZone.Tier)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
||||
}
|
||||
if err := purchase.Validate(); err != nil {
|
||||
if err := purchase.Validate(resumeZone.Tier); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
||||
}
|
||||
cost := float64(purchase.Cost())
|
||||
|
||||
@@ -492,7 +492,7 @@ func zoneTierFromID(zoneID ZoneID) int {
|
||||
|
||||
// handleResourcesCmd lists active nodes in the current room.
|
||||
func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
|
||||
}
|
||||
@@ -512,7 +512,15 @@ func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
|
||||
}
|
||||
nodeID := harvestNodeIDFor(run)
|
||||
nodes := loadHarvestNodes(exp, nodeID)
|
||||
// Seed-persist only for the owner. saveHarvestNodes rewrites the whole
|
||||
// region_state blob (persistRegionState is a last-write-wins UPDATE), and a
|
||||
// member holds neither the expedition row nor the leader's lock — their
|
||||
// `!resources` would clobber whatever the leader's walk wrote since the
|
||||
// snapshot this command read. loadHarvestNodes re-derives the same nodes
|
||||
// from the seed, so a member simply reads without persisting.
|
||||
if isLeader {
|
||||
_ = saveHarvestNodes(exp, nodeID, nodes) // persist seed if first touch
|
||||
}
|
||||
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
regionLabel := exp.CurrentRegion
|
||||
|
||||
@@ -96,7 +96,6 @@ func TestSaveHarvestNodes_DropsLegacyRoomKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestResolveHarvestOutcome_Brackets(t *testing.T) {
|
||||
cases := []struct {
|
||||
roll, dc int
|
||||
|
||||
@@ -89,7 +89,7 @@ func renderRegionLegend(e *Expedition) string {
|
||||
// ── !map command ────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) error {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
@@ -112,13 +112,16 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e
|
||||
|
||||
// Per-run room map. The expedition's zone run is the per-region
|
||||
// dungeon instance; for single-region zones it's the only run.
|
||||
run, _ := getActiveZoneRun(ctx.Sender)
|
||||
run, _, _ := activeZoneRunFor(ctx.Sender)
|
||||
if run != nil {
|
||||
if g, ok := loadZoneGraph(run.ZoneID); ok {
|
||||
b.WriteString("```\n")
|
||||
b.WriteString(renderZoneGraphMap(g, run))
|
||||
b.WriteString("\n```\n")
|
||||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_")
|
||||
if path := renderVisitedPath(g, run); path != "" {
|
||||
b.WriteString("\n**Path:** " + path)
|
||||
}
|
||||
if chain != "" {
|
||||
b.WriteString("\n_Regions: ")
|
||||
b.WriteString(renderRegionLegend(exp))
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/flavor"
|
||||
@@ -14,12 +15,17 @@ import (
|
||||
// is met (e.CurrentDay reflects the new day after rollover):
|
||||
// - First Night : day 2 (+50 XP)
|
||||
// - Week One : day 8 (+200 XP, Thom Krooke discount flag)
|
||||
// - Two Weeks : day 15 (+500 XP, +1 primary stat — deferred to char hookup)
|
||||
// - Two Weeks : day 15 (+500 XP, supply restock + consumable cache)
|
||||
//
|
||||
// Completion milestones fire from the boss-kill path (combat-link hook):
|
||||
// - Patient Zero : status=complete && max_threat_seen ≤ 50 (+10% of XPEarned)
|
||||
// - Survivalist : status=complete && tier ≥ 3 && never forced-extracted (title flag)
|
||||
// - Long Game : status=complete && tier == 5 (legendary item — deferred)
|
||||
// - Survivalist : status=complete && tier ≥ 3 && never forced-extracted (title)
|
||||
// - Long Game : status=complete && tier == 5 (guaranteed legendary item)
|
||||
//
|
||||
// Two Weeks pays out in supplies rather than a stat bump: combat MaxHP is
|
||||
// built from gear/arena/housing in combat_stats.go with no expedition in
|
||||
// scope, and threading one through would leak an expedition-only buff into
|
||||
// the sim's balance corpus. A cache self-expires when the run ends.
|
||||
//
|
||||
// Cartographer (search every room) is fully deferred — needs combat-link
|
||||
// search-hook data not yet plumbed.
|
||||
@@ -86,6 +92,7 @@ type milestoneAward struct {
|
||||
XP int
|
||||
Flavor string
|
||||
Notes string // optional extra ("Thom Krooke discount flag set", etc.)
|
||||
Extra string // grant narration (loot lines, cache contents)
|
||||
}
|
||||
|
||||
func (m milestoneAward) Render() string {
|
||||
@@ -100,6 +107,10 @@ func (m milestoneAward) Render() string {
|
||||
b.WriteString(m.Notes)
|
||||
b.WriteString("_\n")
|
||||
}
|
||||
if m.Extra != "" {
|
||||
b.WriteString(m.Extra)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -112,7 +123,7 @@ func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
award := func(key, title string, xp int, line, notes string) {
|
||||
award := func(key, title string, xp int, line, notes string, grant func() string) {
|
||||
if HasMilestone(e, key) {
|
||||
return
|
||||
}
|
||||
@@ -122,33 +133,105 @@ func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string {
|
||||
if xp > 0 && p.xp != nil {
|
||||
p.xp.GrantXP(id.UserID(e.UserID), xp, "expedition milestone: "+title)
|
||||
}
|
||||
var extra string
|
||||
if grant != nil {
|
||||
extra = grant()
|
||||
}
|
||||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone",
|
||||
"milestone awarded: "+title, line)
|
||||
out = append(out, milestoneAward{
|
||||
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes,
|
||||
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Extra: extra,
|
||||
}.Render())
|
||||
}
|
||||
|
||||
// First Night — survived day 1, now waking on day 2.
|
||||
if e.CurrentDay >= 2 {
|
||||
award(MilestoneKeyFirstNight, "First Night", 50,
|
||||
flavor.Pick(flavor.MilestoneFirstNight), "")
|
||||
flavor.Pick(flavor.MilestoneFirstNight), "", nil)
|
||||
}
|
||||
// Week One — survived day 7, now waking on day 8.
|
||||
if e.CurrentDay >= 8 {
|
||||
award(MilestoneKeyWeekOne, "Week One", 200,
|
||||
flavor.Pick(flavor.MilestoneWeekOne),
|
||||
"Thom Krooke discount flag set on next visit.")
|
||||
"Thom Krooke discount flag set on next visit.", nil)
|
||||
}
|
||||
// Two Weeks — survived day 14, now waking on day 15.
|
||||
if e.CurrentDay >= 15 {
|
||||
award(MilestoneKeyTwoWeeks, "Two Weeks", 500,
|
||||
flavor.Pick(flavor.MilestoneTwoWeeks),
|
||||
"Permanent +1 primary stat (deferred — applied at character hookup).")
|
||||
flavor.Pick(flavor.MilestoneTwoWeeks), "",
|
||||
func() string { return p.grantTwoWeeksCache(e) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// twoWeeksRestockDays — days of rations the Two Weeks cache puts back in the
|
||||
// pack. Never overfills past what the purchased packs can hold.
|
||||
const twoWeeksRestockDays = 3
|
||||
|
||||
// twoWeeksCacheSize — consumables granted alongside the restock.
|
||||
const twoWeeksCacheSize = 3
|
||||
|
||||
// grantTwoWeeksCache restocks supplies and drops a small consumable cache at
|
||||
// the zone's tier. Returns the narration block for the briefing DM.
|
||||
func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string {
|
||||
var lines []string
|
||||
|
||||
if e.Supplies.DailyBurn > 0 {
|
||||
var restocked bool
|
||||
pooled, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
s := fresh.Supplies
|
||||
if s.DailyBurn <= 0 {
|
||||
return s, nil
|
||||
}
|
||||
s.Current += twoWeeksRestockDays * s.DailyBurn
|
||||
if s.Max > 0 && s.Current > s.Max {
|
||||
s.Current = s.Max
|
||||
}
|
||||
restocked = s.Current > fresh.Supplies.Current
|
||||
return s, nil
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
slog.Error("milestone: two weeks restock failed",
|
||||
"expedition", e.ID, "err", err)
|
||||
case restocked:
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max))
|
||||
e.Supplies = pooled
|
||||
default:
|
||||
e.Supplies = pooled
|
||||
}
|
||||
}
|
||||
|
||||
zone, _ := getZone(e.ZoneID)
|
||||
userID := id.UserID(e.UserID)
|
||||
counts := map[string]int{}
|
||||
var order []string
|
||||
for _, item := range consumableCache(int(zone.Tier), twoWeeksCacheSize) {
|
||||
if err := addAdvInventoryItem(userID, item); err != nil {
|
||||
slog.Error("milestone: two weeks cache grant failed",
|
||||
"user", userID, "item", item.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
if counts[item.Name] == 0 {
|
||||
order = append(order, item.Name)
|
||||
}
|
||||
counts[item.Name]++
|
||||
}
|
||||
if len(order) > 0 {
|
||||
var parts []string
|
||||
for _, name := range order {
|
||||
if n := counts[name]; n > 1 {
|
||||
parts = append(parts, fmt.Sprintf("%s ×%d", name, n))
|
||||
} else {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
}
|
||||
lines = append(lines, "🧪 "+strings.Join(parts, ", "))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// AwardCompletionMilestones is called by the combat-link boss-kill path
|
||||
// (deferred) once status flips to 'complete'. It checks Patient Zero,
|
||||
// Survivalist, and Long Game and grants the relevant XP. Returns the
|
||||
@@ -163,7 +246,7 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
|
||||
}
|
||||
zone, _ := getZone(e.ZoneID)
|
||||
var out []string
|
||||
award := func(key, title string, xp int, line, notes string) {
|
||||
award := func(key, title string, xp int, line, notes string, grant func() string) {
|
||||
if HasMilestone(e, key) {
|
||||
return
|
||||
}
|
||||
@@ -173,10 +256,14 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
|
||||
if xp > 0 && p.xp != nil {
|
||||
p.xp.GrantXP(id.UserID(e.UserID), xp, "expedition milestone: "+title)
|
||||
}
|
||||
var extra string
|
||||
if grant != nil {
|
||||
extra = grant()
|
||||
}
|
||||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone",
|
||||
"milestone awarded: "+title, line)
|
||||
out = append(out, milestoneAward{
|
||||
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes,
|
||||
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Extra: extra,
|
||||
}.Render())
|
||||
}
|
||||
|
||||
@@ -184,7 +271,7 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
|
||||
bonus := e.XPEarned / 10
|
||||
award(MilestoneKeyPatientZero, "Patient Zero", bonus,
|
||||
flavor.Pick(flavor.MilestonePatientZero),
|
||||
"+10% XP bonus on the run.")
|
||||
"+10% XP bonus on the run.", nil)
|
||||
}
|
||||
if int(zone.Tier) >= 3 && !forcedExtractedEver {
|
||||
line := flavor.Pick(flavor.MilestoneSurvivalist)
|
||||
@@ -193,12 +280,54 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
|
||||
fmt.Sprint(int(zone.Tier)) + " expedition without a single forced extraction."
|
||||
}
|
||||
award(MilestoneKeySurvivalist, "Survivalist", 0, line,
|
||||
"Title flag recorded; cosmetic item deferred to item-grant hookup.")
|
||||
"Title set — shows on your sheet.",
|
||||
func() string { return p.grantSurvivalistTitle(e, zone) })
|
||||
}
|
||||
if int(zone.Tier) == 5 {
|
||||
award(MilestoneKeyLongGame, "The Long Game", 0,
|
||||
flavor.Pick(flavor.MilestoneTheLongGame),
|
||||
"Guaranteed legendary item deferred to loot-grant hookup.")
|
||||
flavor.Pick(flavor.MilestoneTheLongGame), "",
|
||||
func() string { return p.grantLongGameLegendary(e) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// survivalistTitle — the title written to player_meta.title on a clean T3+ clear.
|
||||
const survivalistTitle = "Survivalist"
|
||||
|
||||
// grantSurvivalistTitle writes the title to the character and posts the
|
||||
// games-room notice. Returns "" — the milestone's own Notes line already
|
||||
// tells the player what happened.
|
||||
func (p *AdventurePlugin) grantSurvivalistTitle(e *Expedition, zone ZoneDefinition) string {
|
||||
userID := id.UserID(e.UserID)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil {
|
||||
slog.Error("milestone: survivalist title load failed", "user", userID, "err", err)
|
||||
return ""
|
||||
}
|
||||
if char.Title != survivalistTitle {
|
||||
char.Title = survivalistTitle
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("milestone: survivalist title save failed", "user", userID, "err", err)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return ""
|
||||
}
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"🎖️ **%s** walked out of %s on their own two feet — Tier %d, no extraction. **Survivalist.**",
|
||||
displayName, zone.Display, int(zone.Tier)))
|
||||
return ""
|
||||
}
|
||||
|
||||
// grantLongGameLegendary hands out the guaranteed Legendary for a T5 clear.
|
||||
func (p *AdventurePlugin) grantLongGameLegendary(e *Expedition) string {
|
||||
mi, ok := pickMagicItemForRarity(RarityLegendary, nil)
|
||||
if !ok {
|
||||
slog.Error("milestone: no legendary in registry", "expedition", e.ID)
|
||||
return ""
|
||||
}
|
||||
return p.dropMagicItemLoot(id.UserID(e.UserID), mi, LootTierLegendary)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -159,3 +160,163 @@ func TestAwardCompletionMilestones_NotCalledOnNonComplete(t *testing.T) {
|
||||
t.Errorf("abandoned should award nothing, got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
// ── N1/A4 — milestone grants ────────────────────────────────────────────────
|
||||
|
||||
func TestCheckDailyMilestones_TwoWeeksGrantsSupplyCache(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-milestone-twoweeks-cache:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
supplies := ExpeditionSupplies{Current: 5, Max: 30, DailyBurn: 2, HarshMod: 1}
|
||||
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp.CurrentDay = 15
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if lines := p.checkDailyMilestones(exp); len(lines) == 0 {
|
||||
t.Fatal("expected milestones on day 15")
|
||||
}
|
||||
if !HasMilestone(exp, MilestoneKeyTwoWeeks) {
|
||||
t.Fatal("two_weeks not recorded")
|
||||
}
|
||||
|
||||
// 5 + 3 days × 2 SU/day = 11, well under the 30 cap.
|
||||
if got := exp.Supplies.Current; got != 11 {
|
||||
t.Errorf("supplies after restock = %v, want 11", got)
|
||||
}
|
||||
stored, err := getExpedition(exp.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stored.Supplies.Current; got != 11 {
|
||||
t.Errorf("persisted supplies = %v, want 11", got)
|
||||
}
|
||||
|
||||
inv, err := loadAdvInventory(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var consumables int
|
||||
for _, it := range inv {
|
||||
if it.Type == "consumable" {
|
||||
consumables++
|
||||
}
|
||||
}
|
||||
if consumables != twoWeeksCacheSize {
|
||||
t.Errorf("cache granted %d consumables, want %d", consumables, twoWeeksCacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantTwoWeeksCache_RestockNeverExceedsMax(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-milestone-twoweeks-cap:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
supplies := ExpeditionSupplies{Current: 29, Max: 30, DailyBurn: 2, HarshMod: 1}
|
||||
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.grantTwoWeeksCache(exp)
|
||||
if got := exp.Supplies.Current; got != 30 {
|
||||
t.Errorf("supplies = %v, want capped at 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwardCompletionMilestones_LongGameGrantsLegendary(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-milestone-longgame:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
|
||||
exp, err := startExpedition(uid, ZoneDragonsLair, "", supplies)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp.Status = ExpeditionStatusComplete
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.AwardCompletionMilestones(exp, false)
|
||||
if !HasMilestone(exp, MilestoneKeyLongGame) {
|
||||
t.Fatal("long_game not recorded")
|
||||
}
|
||||
|
||||
inv, err := loadAdvInventory(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found bool
|
||||
for _, it := range inv {
|
||||
if strings.HasPrefix(it.SkillSource, "magic_item:") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no magic item granted; inventory = %+v", inv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwardCompletionMilestones_SurvivalistSetsTitle(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-milestone-survivalist:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
|
||||
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp.Status = ExpeditionStatusComplete
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.AwardCompletionMilestones(exp, false)
|
||||
if !HasMilestone(exp, MilestoneKeySurvivalist) {
|
||||
t.Fatal("survivalist not recorded")
|
||||
}
|
||||
char, err := loadAdvCharacter(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if char.Title != survivalistTitle {
|
||||
t.Errorf("title = %q, want %q", char.Title, survivalistTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwardCompletionMilestones_SurvivalistSkippedOnForcedExtract(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-milestone-survivalist-forced:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
|
||||
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp.Status = ExpeditionStatusComplete
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.AwardCompletionMilestones(exp, true)
|
||||
if HasMilestone(exp, MilestoneKeySurvivalist) {
|
||||
t.Error("survivalist awarded despite a forced extraction")
|
||||
}
|
||||
char, _ := loadAdvCharacter(uid)
|
||||
if char != nil && char.Title == survivalistTitle {
|
||||
t.Error("title set despite a forced extraction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumableCache_Tier1FallsBackToPoultice(t *testing.T) {
|
||||
items := consumableCache(1, 3)
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("got %d items, want 3", len(items))
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Name != "Berry Poultice" {
|
||||
t.Errorf("tier-1 cache yielded %q, want Berry Poultice", it.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/flavor"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 12 E4c — !region command surface and inter-region travel.
|
||||
@@ -31,7 +33,7 @@ func (p *AdventurePlugin) handleRegionCmd(ctx MessageContext, args string) error
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
@@ -50,6 +52,11 @@ func (p *AdventurePlugin) handleRegionCmd(ctx MessageContext, args string) error
|
||||
case "", "list", "ls", "status":
|
||||
return p.SendDM(ctx.Sender, renderRegionList(exp))
|
||||
case "travel", "advance", "go", "next":
|
||||
if !isLeader {
|
||||
// Transit burns the shared supply pool and moves everyone.
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Your party leader calls the march. `!region` to see where you are.")
|
||||
}
|
||||
return p.regionCmdTravel(ctx, exp)
|
||||
case "help", "?":
|
||||
return p.SendDM(ctx.Sender, regionHelpText())
|
||||
@@ -120,24 +127,45 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
|
||||
"You're already in **%s** — the final region of this zone. Defeat the zone boss to complete the expedition.",
|
||||
cur.Name))
|
||||
}
|
||||
// Burn one day of supplies (transit day).
|
||||
siege := exp.SiegeMode
|
||||
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
|
||||
newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege)
|
||||
exp.Supplies = newSupplies
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't apply transit supply burn: "+err.Error())
|
||||
narrative, err := p.advanceToNextRegion(ctx.Sender, exp, cur, next)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Region transit failed: "+err.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, narrative)
|
||||
}
|
||||
|
||||
// advanceToNextRegion performs an inter-region transition: burns the transit
|
||||
// day + supplies, fires the transit wandering check, retires the outgoing
|
||||
// region's DungeonRun, bumps CurrentRegion + the visited list, and spawns the
|
||||
// incoming region's run (pinning exp.RunID). Returns the rendered transit
|
||||
// narrative block. Shared by the manual `!region travel` command and the
|
||||
// autopilot walk's mid-zone auto-advance — keeping the transit cost identical
|
||||
// on both paths.
|
||||
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
|
||||
// Burn one day of supplies (transit day).
|
||||
var burned float32
|
||||
newSupplies, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||||
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||||
burned = b
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apply transit supply burn: %w", err)
|
||||
}
|
||||
exp.Supplies = newSupplies
|
||||
if err := advanceExpeditionDay(exp.ID); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't advance the expedition day: "+err.Error())
|
||||
return "", fmt.Errorf("advance expedition day: %w", err)
|
||||
}
|
||||
exp.CurrentDay++
|
||||
|
||||
// Transit wandering check (no camp protection — campMod = 0).
|
||||
c, _ := LoadDnDCharacter(ctx.Sender)
|
||||
c, _ := LoadDnDCharacter(userID)
|
||||
var charClass DnDClass
|
||||
charLevel := 1
|
||||
if c != nil {
|
||||
charClass = c.Class
|
||||
charLevel = c.Level
|
||||
}
|
||||
tc := resolveTransitWanderingCheck(exp, charClass, nil)
|
||||
_ = processTransitWanderingCheck(exp, tc)
|
||||
@@ -145,24 +173,20 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
|
||||
// R2 — retire the outgoing region's DungeonRun before mutating
|
||||
// CurrentRegion so retireRegionRun keys the right region.
|
||||
if err := retireRegionRun(exp, cur.ID); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't retire previous region run: "+err.Error())
|
||||
return "", fmt.Errorf("retire previous region run: %w", err)
|
||||
}
|
||||
|
||||
// Update CurrentRegion + visited list.
|
||||
if err := setCurrentRegion(exp, next.ID); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't update current region: "+err.Error())
|
||||
return "", fmt.Errorf("update current region: %w", err)
|
||||
}
|
||||
if _, err := MarkRegionVisited(exp, next.ID); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't mark region visited: "+err.Error())
|
||||
return "", fmt.Errorf("mark region visited: %w", err)
|
||||
}
|
||||
|
||||
// Spawn the new region's DungeonRun and pin exp.RunID.
|
||||
charLevel := 1
|
||||
if c != nil {
|
||||
charLevel = c.Level
|
||||
}
|
||||
if _, err := ensureRegionRun(exp, charLevel); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't outfit the new region: "+err.Error())
|
||||
return "", fmt.Errorf("outfit the new region: %w", err)
|
||||
}
|
||||
|
||||
// Log + flavor.
|
||||
@@ -189,7 +213,7 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
|
||||
if next.IsZoneBoss {
|
||||
b.WriteString("\n_★ Final region — the zone boss is here._")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// resolveTransitWanderingCheck mirrors resolveWanderingCheck (§6.1) but
|
||||
|
||||
@@ -2,6 +2,9 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Phase 12 E1b — Supply procurement, daily burn, and depletion effects.
|
||||
@@ -11,15 +14,115 @@ import (
|
||||
const (
|
||||
SupplyPackStandardSU = 10
|
||||
SupplyPackStandardCoins = 50
|
||||
SupplyPackStandardMax = 3 // per expedition
|
||||
|
||||
SupplyPackDeluxeSU = 20
|
||||
SupplyPackDeluxeCoins = 90
|
||||
SupplyPackDeluxeMax = 1 // per expedition
|
||||
|
||||
SupplyForageMaxSU = 4 // 1d4 cap (Ranger, WIS DC 12) — §4.2
|
||||
)
|
||||
|
||||
// supplyPackCaps returns the per-tier maximum standard and deluxe pack
|
||||
// counts a player can buy for an expedition. D5-a: caps now scale by
|
||||
// zone tier so a T5 loadout actually clears DailyBurn(raw) × intended
|
||||
// days × harsh-multiplier — see gogobee_long_expedition_plan.md §D5.
|
||||
// Intended-day anchors come from the §2 target table (T1=2 → T5=7).
|
||||
func supplyPackCaps(tier ZoneTier) (standard, deluxe int) {
|
||||
switch tier {
|
||||
case ZoneTierBeginner:
|
||||
return 2, 1
|
||||
case ZoneTierApprentice:
|
||||
return 2, 1
|
||||
case ZoneTierJourneyman:
|
||||
return 3, 1
|
||||
case ZoneTierVeteran:
|
||||
return 5, 1
|
||||
case ZoneTierLegendary:
|
||||
return 7, 2
|
||||
}
|
||||
return 3, 1
|
||||
}
|
||||
|
||||
// expeditionTargetDays returns the §2 target-duration anchor for a zone
|
||||
// tier — what the supply-cap math, balanced loadout, and the
|
||||
// "Day X / ~Y expected" line in !expedition status are all sized against.
|
||||
func expeditionTargetDays(tier ZoneTier) int {
|
||||
switch tier {
|
||||
case ZoneTierBeginner:
|
||||
return 2
|
||||
case ZoneTierApprentice:
|
||||
return 3
|
||||
case ZoneTierJourneyman:
|
||||
return 4
|
||||
case ZoneTierVeteran:
|
||||
return 5
|
||||
case ZoneTierLegendary:
|
||||
return 7
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
// SupplyLoadout names a tier-scaled preset purchase. D5-b: empty-arg
|
||||
// `!expedition start <zone>` now prompts the player to pick one of these
|
||||
// rather than silently defaulting to 1 standard pack. Raw `Ns Md` syntax
|
||||
// remains the advanced override.
|
||||
type SupplyLoadout int
|
||||
|
||||
const (
|
||||
LoadoutLean SupplyLoadout = iota
|
||||
LoadoutBalanced
|
||||
LoadoutHeavy
|
||||
)
|
||||
|
||||
// loadoutPurchase returns the SupplyPurchase for a preset at a tier.
|
||||
// Lean: covers intended days at raw daily burn (cheap, no harsh buffer).
|
||||
// Balanced: ~harsh-ready for an intended-length run (recommended).
|
||||
// Heavy: equals supplyPackCaps — the harsh×3 ceiling D5-a sized for.
|
||||
func loadoutPurchase(tier ZoneTier, l SupplyLoadout) SupplyPurchase {
|
||||
switch l {
|
||||
case LoadoutHeavy:
|
||||
std, dlx := supplyPackCaps(tier)
|
||||
return SupplyPurchase{StandardPacks: std, DeluxePacks: dlx}
|
||||
case LoadoutBalanced:
|
||||
switch tier {
|
||||
case ZoneTierBeginner, ZoneTierApprentice:
|
||||
return SupplyPurchase{StandardPacks: 2}
|
||||
case ZoneTierJourneyman:
|
||||
return SupplyPurchase{StandardPacks: 3}
|
||||
case ZoneTierVeteran:
|
||||
return SupplyPurchase{StandardPacks: 5}
|
||||
case ZoneTierLegendary:
|
||||
return SupplyPurchase{StandardPacks: 5, DeluxePacks: 1}
|
||||
}
|
||||
return SupplyPurchase{StandardPacks: 2}
|
||||
default: // LoadoutLean
|
||||
switch tier {
|
||||
case ZoneTierBeginner, ZoneTierApprentice:
|
||||
return SupplyPurchase{StandardPacks: 1}
|
||||
case ZoneTierJourneyman:
|
||||
return SupplyPurchase{StandardPacks: 2}
|
||||
case ZoneTierVeteran:
|
||||
return SupplyPurchase{StandardPacks: 3}
|
||||
case ZoneTierLegendary:
|
||||
return SupplyPurchase{StandardPacks: 5}
|
||||
}
|
||||
return SupplyPurchase{StandardPacks: 1}
|
||||
}
|
||||
}
|
||||
|
||||
// parseLoadoutToken returns the preset selected by tok, if any. Accepts
|
||||
// short forms (l/b/h) and a couple of synonyms. Empty/unknown → false.
|
||||
func parseLoadoutToken(tok string) (SupplyLoadout, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(tok)) {
|
||||
case "lean", "l", "light":
|
||||
return LoadoutLean, true
|
||||
case "balanced", "b", "bal", "standard":
|
||||
return LoadoutBalanced, true
|
||||
case "heavy", "h", "max", "full":
|
||||
return LoadoutHeavy, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// supplyDailyBurn returns the base SU/day for a zone tier (§4.1).
|
||||
// Tier 1: 1, Tier 2: 1.5, Tier 3: 2, Tier 4: 3, Tier 5: 4.
|
||||
func supplyDailyBurn(tier ZoneTier) float32 {
|
||||
@@ -56,6 +159,45 @@ func supplyHarshMultiplier(tier ZoneTier) float32 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// applyRangerForage grants the Ranger's daily 1d4-SU forage yield (§4.2,
|
||||
// wired in D5-c). Returns the SU added — 0 for non-Rangers, when the
|
||||
// player has already foraged today, or when supplies are already at Max.
|
||||
// The grant is headroom-capped so a Heavy loadout doesn't overflow its
|
||||
// purchased ceiling. rng is the test-injectable [0,n) source; nil falls
|
||||
// back to math/rand. Caller owns the persistence.
|
||||
//
|
||||
// Sizing rationale: with D5-a caps so generous (Lean T5 = 50 SU vs ~14 SU
|
||||
// burned over a 7-day intended run at phase5B×0.5), this perk operates
|
||||
// as a quiet Lean-loadout cushion, not a Heavy multiplier — average +2.5
|
||||
// SU/day off a Ranger is roughly one extra day of late-stage T5 burn
|
||||
// across a full expedition.
|
||||
func applyRangerForage(e *Expedition, c *DnDCharacter, rng func(int) int) float32 {
|
||||
if e == nil || c == nil || c.Class != ClassRanger {
|
||||
return 0
|
||||
}
|
||||
if e.Supplies.ForagedToday {
|
||||
return 0
|
||||
}
|
||||
headroom := e.Supplies.Max - e.Supplies.Current
|
||||
if headroom <= 0 {
|
||||
e.Supplies.ForagedToday = true
|
||||
return 0
|
||||
}
|
||||
var roll int
|
||||
if rng != nil {
|
||||
roll = rng(SupplyForageMaxSU) + 1
|
||||
} else {
|
||||
roll = rand.IntN(SupplyForageMaxSU) + 1
|
||||
}
|
||||
gain := float32(roll)
|
||||
if gain > headroom {
|
||||
gain = headroom
|
||||
}
|
||||
e.Supplies.Current += gain
|
||||
e.Supplies.ForagedToday = true
|
||||
return gain
|
||||
}
|
||||
|
||||
// SupplyDepletionState classifies remaining supply ratio (§4.3).
|
||||
type SupplyDepletionState int
|
||||
|
||||
@@ -122,18 +264,19 @@ func (p SupplyPurchase) Cost() int {
|
||||
return p.StandardPacks*SupplyPackStandardCoins + p.DeluxePacks*SupplyPackDeluxeCoins
|
||||
}
|
||||
|
||||
// Validate enforces §4.2 caps (max 3 standard, max 1 deluxe, no negatives,
|
||||
// at least one pack purchased — an expedition without supplies is not a
|
||||
// legal start).
|
||||
func (p SupplyPurchase) Validate() error {
|
||||
// Validate enforces §4.2 caps (no negatives, at least one pack
|
||||
// purchased — an expedition without supplies is not a legal start) and
|
||||
// the per-tier maximums from supplyPackCaps.
|
||||
func (p SupplyPurchase) Validate(tier ZoneTier) error {
|
||||
if p.StandardPacks < 0 || p.DeluxePacks < 0 {
|
||||
return fmt.Errorf("supply pack counts must be non-negative")
|
||||
}
|
||||
if p.StandardPacks > SupplyPackStandardMax {
|
||||
return fmt.Errorf("standard packs capped at %d (got %d)", SupplyPackStandardMax, p.StandardPacks)
|
||||
stdCap, dlxCap := supplyPackCaps(tier)
|
||||
if p.StandardPacks > stdCap {
|
||||
return fmt.Errorf("standard packs capped at %d for T%d (got %d)", stdCap, int(tier), p.StandardPacks)
|
||||
}
|
||||
if p.DeluxePacks > SupplyPackDeluxeMax {
|
||||
return fmt.Errorf("deluxe packs capped at %d (got %d)", SupplyPackDeluxeMax, p.DeluxePacks)
|
||||
if p.DeluxePacks > dlxCap {
|
||||
return fmt.Errorf("deluxe packs capped at %d for T%d (got %d)", dlxCap, int(tier), p.DeluxePacks)
|
||||
}
|
||||
if p.StandardPacks == 0 && p.DeluxePacks == 0 {
|
||||
return fmt.Errorf("expedition requires at least one supply pack")
|
||||
@@ -156,6 +299,23 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies {
|
||||
}
|
||||
}
|
||||
|
||||
// addSupplyPurchase folds a joining member's packs into the party's pool
|
||||
// (N3/P6b). Everyone carries their own rations in, so both Current and Max rise
|
||||
// by what they bought.
|
||||
//
|
||||
// Raising Max alongside Current is what keeps supplyDepletion honest: it reads
|
||||
// the ratio, and a member arriving with a full pack must not read as the party
|
||||
// suddenly starving. On Day 1 — the only day an invite is legal — nothing has
|
||||
// burned yet, so Current == Max and the fold is exact.
|
||||
func addSupplyPurchase(s ExpeditionSupplies, p SupplyPurchase) ExpeditionSupplies {
|
||||
total := p.Total()
|
||||
s.Current += total
|
||||
s.Max += total
|
||||
s.PacksStandard += p.StandardPacks
|
||||
s.PacksDeluxe += p.DeluxePacks
|
||||
return s
|
||||
}
|
||||
|
||||
// applyDailyBurn deducts one day's supplies from the snapshot (caller
|
||||
// persists). Returns the new snapshot and the SU consumed.
|
||||
//
|
||||
@@ -163,6 +323,7 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies {
|
||||
// - siege overrides everything with a hard 2× floor (even for tier 1
|
||||
// where HarshMod is 1×) — the dungeon is actively starving you out.
|
||||
// - otherwise, harshActive applies HarshMod (zone-tier scaled).
|
||||
//
|
||||
// phase5BDailyBurnRatePct is the shipped daily-burn multiplier from
|
||||
// Phase 3-B's sweep + Phase 5-B's post-buff re-validation. 50 means
|
||||
// "half live burn" — needed because the Phase 5-B player power floor
|
||||
@@ -172,14 +333,57 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies {
|
||||
// (re-validated under HP buff, shipped).
|
||||
const phase5BDailyBurnRatePct = 50
|
||||
|
||||
// applyDailyBurn is the solo-rate burn. Since P6b folded the roster in, the live
|
||||
// callers all go through applyExpeditionDailyBurn instead; this survives as the
|
||||
// fixture the supply tests pin the solo rate against.
|
||||
func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) {
|
||||
return applyDailyBurnP(s, harshActive, siege, phase5BDailyBurnRatePct)
|
||||
}
|
||||
|
||||
// partyBurnEfficiency is the per-head discount a party gets on rations: three
|
||||
// people eat 2.4 days' worth per day, not 3. Sharing a fire, a pot and a watch
|
||||
// rota is worth something, and it is the one place C1 asked for a party to be
|
||||
// mechanically better off than three solo runs.
|
||||
//
|
||||
// It is an exact ratio rather than the literal 0.8 because the rate is truncated
|
||||
// to an int: float32(50*3) * 0.8 evaluates a hair under 120, and int() would
|
||||
// round it to 119 — a silent, permanent tax on every party of three.
|
||||
const (
|
||||
partyBurnEfficiencyNum = 4
|
||||
partyBurnEfficiencyDen = 5
|
||||
)
|
||||
|
||||
// applyExpeditionDailyBurn is applyDailyBurn with the roster folded in: a party
|
||||
// of N burns N × 0.8 days of supplies per day, against a pool that P6b's
|
||||
// addSupplyPurchase has already grown by everyone's packs.
|
||||
//
|
||||
// A solo expedition — every expedition that has ever run, and everything the
|
||||
// balance corpus measured — resolves to phase5BDailyBurnRatePct exactly, so this
|
||||
// is a no-op for it down to the float. On a roster read error it burns the solo
|
||||
// rate: undercharging a party is a bug, starving them on a SQLite hiccup is a
|
||||
// lost expedition.
|
||||
func applyExpeditionDailyBurn(e *Expedition, harshActive, siege bool) (ExpeditionSupplies, float32) {
|
||||
return applyDailyBurnP(e.Supplies, harshActive, siege, expeditionBurnRatePct(e.ID))
|
||||
}
|
||||
|
||||
func expeditionBurnRatePct(expeditionID string) int {
|
||||
n, err := partySize(expeditionID)
|
||||
if err != nil {
|
||||
slog.Warn("expedition: party size read failed, burning at the solo rate",
|
||||
"expedition", expeditionID, "err", err)
|
||||
return phase5BDailyBurnRatePct
|
||||
}
|
||||
if n <= 1 {
|
||||
return phase5BDailyBurnRatePct
|
||||
}
|
||||
return phase5BDailyBurnRatePct * n * partyBurnEfficiencyNum / partyBurnEfficiencyDen
|
||||
}
|
||||
|
||||
// applyDailyBurnP is the rate-parameterized form used by the Phase 3-B
|
||||
// sim harness lever sweep. burnRatePct == 0 means "use live" (100%);
|
||||
// any positive value scales the final per-day burn by that percent
|
||||
// (e.g. 50 = half burn). Live callers always go through applyDailyBurn.
|
||||
// (e.g. 50 = half burn). Live callers reach it through applyExpeditionDailyBurn,
|
||||
// which supplies the roster-scaled rate.
|
||||
// See gogobee_expedition_difficulty.md Phase 3-B.
|
||||
func applyDailyBurnP(s ExpeditionSupplies, harshActive, siege bool, burnRatePct int) (ExpeditionSupplies, float32) {
|
||||
burn := s.DailyBurn
|
||||
|
||||
@@ -92,19 +92,52 @@ func TestSupplyAllowsLongRest(t *testing.T) {
|
||||
func TestSupplyPurchase_Validate(t *testing.T) {
|
||||
cases := []struct {
|
||||
p SupplyPurchase
|
||||
tier ZoneTier
|
||||
wantErr bool
|
||||
}{
|
||||
{SupplyPurchase{StandardPacks: 1}, false},
|
||||
{SupplyPurchase{StandardPacks: 3, DeluxePacks: 1}, false}, // max
|
||||
{SupplyPurchase{StandardPacks: 4}, true}, // over standard cap
|
||||
{SupplyPurchase{DeluxePacks: 2}, true}, // over deluxe cap
|
||||
{SupplyPurchase{StandardPacks: -1}, true},
|
||||
{SupplyPurchase{}, true}, // none purchased
|
||||
// T3 is the only tier whose caps are unchanged from the pre-D5
|
||||
// shape (3 standard / 1 deluxe); use it to anchor the
|
||||
// happy-path / over-cap parity assertions.
|
||||
{SupplyPurchase{StandardPacks: 1}, ZoneTierJourneyman, false},
|
||||
{SupplyPurchase{StandardPacks: 3, DeluxePacks: 1}, ZoneTierJourneyman, false},
|
||||
{SupplyPurchase{StandardPacks: 4}, ZoneTierJourneyman, true},
|
||||
{SupplyPurchase{DeluxePacks: 2}, ZoneTierJourneyman, true},
|
||||
{SupplyPurchase{StandardPacks: -1}, ZoneTierJourneyman, true},
|
||||
{SupplyPurchase{}, ZoneTierJourneyman, true}, // none purchased
|
||||
|
||||
// D5-a per-tier caps. T1/T2 tighten to (2,1); T4 widens to (5,1);
|
||||
// T5 widens to (7,2). A T3-legal 3-standard loadout fails on T1.
|
||||
{SupplyPurchase{StandardPacks: 2, DeluxePacks: 1}, ZoneTierBeginner, false},
|
||||
{SupplyPurchase{StandardPacks: 3}, ZoneTierBeginner, true},
|
||||
{SupplyPurchase{StandardPacks: 5, DeluxePacks: 1}, ZoneTierVeteran, false},
|
||||
{SupplyPurchase{StandardPacks: 6}, ZoneTierVeteran, true},
|
||||
{SupplyPurchase{StandardPacks: 7, DeluxePacks: 2}, ZoneTierLegendary, false},
|
||||
{SupplyPurchase{StandardPacks: 7, DeluxePacks: 3}, ZoneTierLegendary, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := c.p.Validate()
|
||||
err := c.p.Validate(c.tier)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("%+v: err = %v, wantErr=%v", c.p, err, c.wantErr)
|
||||
t.Errorf("%+v T%d: err = %v, wantErr=%v", c.p, int(c.tier), err, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupplyPackCaps_PerTier(t *testing.T) {
|
||||
cases := []struct {
|
||||
tier ZoneTier
|
||||
wantStd int
|
||||
wantDlx int
|
||||
}{
|
||||
{ZoneTierBeginner, 2, 1},
|
||||
{ZoneTierApprentice, 2, 1},
|
||||
{ZoneTierJourneyman, 3, 1},
|
||||
{ZoneTierVeteran, 5, 1},
|
||||
{ZoneTierLegendary, 7, 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s, d := supplyPackCaps(c.tier)
|
||||
if s != c.wantStd || d != c.wantDlx {
|
||||
t.Errorf("T%d caps: got (%d,%d), want (%d,%d)", int(c.tier), s, d, c.wantStd, c.wantDlx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +166,67 @@ func TestMakeSupplies_FillsFromTier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRangerForage(t *testing.T) {
|
||||
ranger := &DnDCharacter{Class: ClassRanger}
|
||||
fighter := &DnDCharacter{Class: ClassFighter}
|
||||
det := func(n int) int { return n - 1 } // always rolls the max (1d4 = 4)
|
||||
|
||||
// Ranger, fresh day, plenty of headroom: max 1d4 = 4 SU added, flag set.
|
||||
exp := &Expedition{Supplies: ExpeditionSupplies{Current: 10, Max: 50}}
|
||||
if gain := applyRangerForage(exp, ranger, det); gain != 4 {
|
||||
t.Errorf("ranger forage gain = %v, want 4", gain)
|
||||
}
|
||||
if exp.Supplies.Current != 14 {
|
||||
t.Errorf("current after forage = %v, want 14", exp.Supplies.Current)
|
||||
}
|
||||
if !exp.Supplies.ForagedToday {
|
||||
t.Error("ForagedToday should be set after a successful grant")
|
||||
}
|
||||
|
||||
// Same day, second call: no-op (already foraged).
|
||||
if gain := applyRangerForage(exp, ranger, det); gain != 0 {
|
||||
t.Errorf("repeat forage gain = %v, want 0", gain)
|
||||
}
|
||||
if exp.Supplies.Current != 14 {
|
||||
t.Errorf("current after repeat = %v, want 14", exp.Supplies.Current)
|
||||
}
|
||||
|
||||
// Non-Ranger: never grants, never sets the flag.
|
||||
exp2 := &Expedition{Supplies: ExpeditionSupplies{Current: 10, Max: 50}}
|
||||
if gain := applyRangerForage(exp2, fighter, det); gain != 0 {
|
||||
t.Errorf("non-ranger gain = %v, want 0", gain)
|
||||
}
|
||||
if exp2.Supplies.ForagedToday {
|
||||
t.Error("non-ranger should not stamp ForagedToday")
|
||||
}
|
||||
|
||||
// Headroom cap: 2 SU short of Max → grant clamps to 2 even on a max roll.
|
||||
exp3 := &Expedition{Supplies: ExpeditionSupplies{Current: 48, Max: 50}}
|
||||
if gain := applyRangerForage(exp3, ranger, det); gain != 2 {
|
||||
t.Errorf("headroom-capped gain = %v, want 2", gain)
|
||||
}
|
||||
if exp3.Supplies.Current != 50 {
|
||||
t.Errorf("current should clamp to Max, got %v", exp3.Supplies.Current)
|
||||
}
|
||||
|
||||
// Already at Max: no grant, but flag still set so the day's roll is spent.
|
||||
exp4 := &Expedition{Supplies: ExpeditionSupplies{Current: 50, Max: 50}}
|
||||
if gain := applyRangerForage(exp4, ranger, det); gain != 0 {
|
||||
t.Errorf("full-bag gain = %v, want 0", gain)
|
||||
}
|
||||
if !exp4.Supplies.ForagedToday {
|
||||
t.Error("full-bag should still consume the day's forage attempt")
|
||||
}
|
||||
|
||||
// Nil character / nil expedition: never panics, returns 0.
|
||||
if gain := applyRangerForage(exp, nil, det); gain != 0 {
|
||||
t.Errorf("nil char gain = %v, want 0", gain)
|
||||
}
|
||||
if gain := applyRangerForage(nil, ranger, det); gain != 0 {
|
||||
t.Errorf("nil exp gain = %v, want 0", gain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) {
|
||||
// Phase 5-B (shipped): applyDailyBurn now scales by
|
||||
// phase5BDailyBurnRatePct = 50 by default — every expected value
|
||||
|
||||
@@ -455,6 +455,7 @@ const AbyssInstabilityPerDay = 5
|
||||
const AbyssMaxInstability = 100
|
||||
|
||||
// AbyssInstabilityBandFor classifies the §7.6 instability tiers.
|
||||
//
|
||||
// normal: 0–20
|
||||
// mild: 21–40 (-1 WIS)
|
||||
// warp: 41–60 (rooms shift order)
|
||||
|
||||
@@ -701,6 +701,7 @@ func TestAbyss_DailyInstabilityIncrements(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
p := &AdventurePlugin{}
|
||||
// Three briefings → instability should hit 15.
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -732,6 +733,7 @@ func TestAbyss_UnravelingDoublesBurn(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
// Set instability to 85 (unravel band).
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET temporal_stack = 85 WHERE expedition_id = ?`,
|
||||
@@ -760,6 +762,7 @@ func TestAbyss_CollapseFailsExpedition(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
// Set instability to 95 — next daily +5 lands on 100 (collapse).
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET temporal_stack = 95 WHERE expedition_id = ?`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user