mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
1 Commits
6d402343e6
...
link-thumb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b8f4584f |
49
.env.example
49
.env.example
@@ -1,42 +1,9 @@
|
||||
# ---- 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
|
||||
|
||||
@@ -161,17 +128,3 @@ 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,14 +91,7 @@ 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` |
|
||||
|
||||
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).
|
||||
| `BOT_PASSWORD` | Bot's Matrix password |
|
||||
|
||||
### Core (optional)
|
||||
|
||||
|
||||
@@ -3,33 +3,22 @@
|
||||
// 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 ...
|
||||
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
|
||||
// [-bank 1000] [-cap 50] [-log] [-data DIR]
|
||||
//
|
||||
// Matrix mode (cartesian sweep over classes × levels × zones × N runs,
|
||||
// one JSON object per stdout line, log suppressed by default):
|
||||
//
|
||||
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \
|
||||
// -zones goblin_warrens,wolf_den -runs 20
|
||||
// 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"
|
||||
|
||||
@@ -43,12 +32,9 @@ 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)")
|
||||
|
||||
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
||||
@@ -57,27 +43,10 @@ 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)")
|
||||
companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.")
|
||||
|
||||
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
|
||||
@@ -89,80 +58,14 @@ func main() {
|
||||
includeLog = *logFlag
|
||||
}
|
||||
})
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, includeLog)
|
||||
return
|
||||
}
|
||||
|
||||
if *realUser != "" {
|
||||
if *dataDir == "" {
|
||||
fail("-real-user requires -data pointing at a dir containing a populated gogobee.db")
|
||||
}
|
||||
if *party != 1 {
|
||||
fail("-real-user does not support -party yet (would need every seat to be a real, tier-eligible char)")
|
||||
}
|
||||
runReal(*realUser, *zone, *dataDir, *bank, *cap, *days, *logFlag)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers, *companion)
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *logFlag)
|
||||
}
|
||||
|
||||
// runReal drives an existing character loaded from dataDir's gogobee.db through
|
||||
// a real expedition. dataDir should be a COPY of prod — db.Init runs additive
|
||||
// migrations against it, and the run mutates HP/coin/inventory. Never point this
|
||||
// at the live prod file or at ./data.
|
||||
func runReal(userTag, zone, dataDir string, bank float64, cap, days int, includeLog bool) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
fail("init runner:", err)
|
||||
}
|
||||
defer runner.Close()
|
||||
|
||||
uid := id.UserID(userTag)
|
||||
c, err := runner.PrepareRealCharacter(uid, bank)
|
||||
if err != nil {
|
||||
fail("prepare real character:", err)
|
||||
}
|
||||
res, err := runner.RunExpedition(uid, plugin.ZoneID(zone), cap, days)
|
||||
if res != nil {
|
||||
res.Class = string(c.Class) // real subclass/race aren't in SimResult; class is the useful key
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
if err != nil {
|
||||
fail("run:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, companion string) {
|
||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap int, includeLog bool) {
|
||||
dir := dataDir
|
||||
if dir == "" {
|
||||
var err error
|
||||
@@ -173,7 +76,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, days, followers, companion)
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap)
|
||||
if err != nil {
|
||||
if res != nil {
|
||||
if !includeLog {
|
||||
@@ -189,136 +92,47 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
|
||||
emitIndented(res)
|
||||
}
|
||||
|
||||
// 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, companion string) {
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, includeLog bool) {
|
||||
cs := splitNonEmpty(classes)
|
||||
ls := parseLevels(levels)
|
||||
zs := splitNonEmpty(zones)
|
||||
if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 {
|
||||
fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0")
|
||||
}
|
||||
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)
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
for _, c := range cs {
|
||||
for _, lv := range ls {
|
||||
for _, z := range zs {
|
||||
for r := 0; r < runs; r++ {
|
||||
work = append(work, matrixJob{class: c, level: lv, zone: z, rep: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, companion)
|
||||
}
|
||||
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, companion 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 companion != "" {
|
||||
args = append(args, "-companion", companion)
|
||||
}
|
||||
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, companion string) (*plugin.SimResult, error) {
|
||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init runner: %w", err)
|
||||
@@ -329,33 +143,7 @@ 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")
|
||||
|
||||
// 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)
|
||||
}
|
||||
runner.Companion = companion
|
||||
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))
|
||||
return runner.RunExpedition(uid, zone, cap)
|
||||
}
|
||||
|
||||
func emitIndented(res *plugin.SimResult) {
|
||||
|
||||
@@ -129,7 +129,7 @@ var (
|
||||
reSpaces = regexp.MustCompile(`\s+`)
|
||||
reOrphanMustMake = regexp.MustCompile(`(?i)\s+must\s+(?:make|succeed on|attempt)([.,;])`)
|
||||
reOrphanTrailingAnd = regexp.MustCompile(`(?i),?\s+and\.`)
|
||||
descScrub = []*regexp.Regexp{
|
||||
descScrub = []*regexp.Regexp{
|
||||
// Range / distance jargon.
|
||||
regexp.MustCompile(`(?i)\s*\bwithin range\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bwithin \d+ feet(?: of [a-z ]+?)?`),
|
||||
|
||||
@@ -93,11 +93,11 @@ func TestCleanDescRepairsOrphans(t *testing.T) {
|
||||
// TestStripNameParenthetical — bestiary R21 / magic-item alias names.
|
||||
func TestStripNameParenthetical(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Giant Rat (Diseased)": "Giant Rat",
|
||||
"Deep Gnome (Svirfneblin)": "Deep Gnome",
|
||||
"Giant Rat (Diseased)": "Giant Rat",
|
||||
"Deep Gnome (Svirfneblin)": "Deep Gnome",
|
||||
"Stone of Good Luck (Luckstone)": "Stone of Good Luck",
|
||||
"Ordinary Name": "Ordinary Name",
|
||||
"": "",
|
||||
"Ordinary Name": "Ordinary Name",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := stripNameParenthetical(in); got != want {
|
||||
|
||||
@@ -88,18 +88,18 @@ func must(err error) {
|
||||
// open5eSpell is the subset of the Open5e v1 spell schema we consume. Unknown
|
||||
// fields are ignored; the vendored JSON keeps the full payload.
|
||||
type open5eSpell struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
HigherLevel string `json:"higher_level"`
|
||||
Range string `json:"range"`
|
||||
Material string `json:"material"`
|
||||
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
|
||||
Duration string `json:"duration"`
|
||||
RequiresConcentration bool `json:"requires_concentration"`
|
||||
CastingTime string `json:"casting_time"`
|
||||
LevelInt int `json:"level_int"`
|
||||
School string `json:"school"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
HigherLevel string `json:"higher_level"`
|
||||
Range string `json:"range"`
|
||||
Material string `json:"material"`
|
||||
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
|
||||
Duration string `json:"duration"`
|
||||
RequiresConcentration bool `json:"requires_concentration"`
|
||||
CastingTime string `json:"casting_time"`
|
||||
LevelInt int `json:"level_int"`
|
||||
School string `json:"school"`
|
||||
// SpellLists is the structured class list, but the SRD dump leaves it
|
||||
// incomplete (no paladin entries at all). DndClass is the free-text
|
||||
// "Druid, Wizard" field and is the more complete source — mapClasses
|
||||
|
||||
20
go.mod
20
go.mod
@@ -11,10 +11,9 @@ 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.1
|
||||
maunium.net/go/mautrix v0.28.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
@@ -22,27 +21,26 @@ 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.45 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/zerolog v1.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.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
|
||||
go.mau.fi/util v0.9.9 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
47
go.sum
47
go.sum
@@ -10,8 +10,6 @@ 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=
|
||||
@@ -38,8 +36,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.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
|
||||
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914 h1:xXPuFr3PVM4p6Vw3j0CP29oWYRVKO3cPZjR6D7BxggQ=
|
||||
@@ -75,18 +73,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.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg=
|
||||
go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A=
|
||||
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
|
||||
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.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/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -94,8 +92,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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -105,8 +103,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -114,8 +112,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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -128,8 +126,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -148,24 +146,23 @@ 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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 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.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
|
||||
maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY=
|
||||
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
# Adventure — the big update (2026-07-09 → 2026-07-11)
|
||||
|
||||
62 commits, ~31k lines, 7 phases. All merged to `main` and live in prod as of
|
||||
2026-07-11. Working-tree doc; do not commit.
|
||||
|
||||
---
|
||||
|
||||
## The one-line version
|
||||
|
||||
Adventure stopped being a solo grind. You can now bring friends, fight each
|
||||
other, fight the world together, prestige past the level cap, and there's an
|
||||
actual story running underneath it — and Pete reports on all of it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Co-op — you can bring people now (N3)
|
||||
|
||||
The headline. The combat engine was 1-versus-1 at its core; it was rewritten
|
||||
into an N-player engine with real initiative, and every seat gets its own turn.
|
||||
|
||||
- `!expedition invite @user` — leader, Day 1 only
|
||||
- `!expedition accept` / `decline` / `party` / `leave`
|
||||
- Everyone buys their own supply pack. Everyone acts on their own turn.
|
||||
- The enemy's action economy scales to the party, so four people don't trivialize
|
||||
a room.
|
||||
|
||||
## 2. Fight each other (N6)
|
||||
|
||||
- `!duel @user [stake]`, then `!duel accept` / `decline` / `status`.
|
||||
Staked, and **nobody dies** — it's a bout, not a mugging.
|
||||
- `!rivals` for your record, `!rivals board` for room-wide standings.
|
||||
|
||||
## 3. Fight the world together (N6)
|
||||
|
||||
- `!adventure worldboss` — the Siege. One communal bout a day; `worldboss fight`
|
||||
to take your swing at it. Everybody's damage counts toward the same health bar.
|
||||
|
||||
## 4. The Shadow (N6)
|
||||
|
||||
- `!adventure shadow` — a rival adventurer who runs the dungeon on their own
|
||||
schedule whether you play or not, and you can check how your run stacks up
|
||||
against theirs.
|
||||
|
||||
## 5. The Town (N4)
|
||||
|
||||
Housing finally pays off, and the room has a civic life.
|
||||
|
||||
- **T3 payoffs** — trophy hall + workshop; a long rest at home now grants a
|
||||
**well-rested** buff that scales with your house tier.
|
||||
- **T4 Estate vault** — `!adventure vault` / `vault store <item>` / `vault take
|
||||
<item>`. 10 protected slots. Safe from `!sell all`, safe from combat.
|
||||
- **A second pet** at Tier 4 — it shows up on its own.
|
||||
- **Gifting** — `!give <item> @user`. Small handling fee to the community pot.
|
||||
- **Registries** — `!town` (civic pride, housing street, pet showcase),
|
||||
`!graveyard` (recent deaths), `!rivals`.
|
||||
|
||||
## 6. Prestige and a world that moves (N7)
|
||||
|
||||
- **Renown** — XP past L20 no longer evaporates. It accrues into a Renown rank
|
||||
that shows on your `!sheet` and gets announced when it ticks up.
|
||||
- **The Omen** — one rotating world modifier per week. TwinBee tells you what's
|
||||
in the air in the morning DM.
|
||||
- **Seasonal events** — holiday weeks re-skin the day's events and flavor.
|
||||
- **Achievements** — a whole Renown wing added to `!achievements`.
|
||||
|
||||
## 7. The chase (N2)
|
||||
|
||||
- **Tempering** — `!adventure temper` pushes a magic item one rarity step higher
|
||||
at the blacksmith. Euros plus a T5 material (one per clear — *that's* the real
|
||||
gate).
|
||||
- **Arena seasons** — `!arena leaderboard` is now the current season; champions
|
||||
get titles. `!arena stats` keeps your lifetime record.
|
||||
|
||||
## 8. Story (N5 — the Hollow King)
|
||||
|
||||
- **`!adventure journal`** — recover campaign pages through the zones and read
|
||||
them back. Bosses now have epilogues, and there's a real finale.
|
||||
- NPC arcs land as DMs mid-run — Misty, Robbie, and Thom all have somewhere to
|
||||
go now.
|
||||
|
||||
## 9. Backtracking (C5)
|
||||
|
||||
- **`!revisit <N>`** — walk back one room from the Path strip in `!map` for +1
|
||||
threat. Blocked mid-fight, at a fork, and when it's too hot to double back.
|
||||
|
||||
## 10. Pete reports on it (Pete Adventure News)
|
||||
|
||||
Guild events — zone firsts, deaths, achievements — are now narrated into a live
|
||||
news feed by Pete, the deadpan announcer.
|
||||
|
||||
- `news.parodia.dev/adventure` (feed, permalinks, daily digest)
|
||||
- Live beats in the games room
|
||||
- `!news` to read; `!news optout` if you'd rather not be named.
|
||||
|
||||
## 11. Restoration + plumbing (N1, unglamorous but real)
|
||||
|
||||
- The drop systems Phase R orphaned are wired back up; milestone rewards that
|
||||
were stubs actually pay out.
|
||||
- Lottery ticket sales fund the community pot.
|
||||
- Fixed outside Adventure but shipped alongside: URL previews stopped dropping
|
||||
thumbnails, and the bot stopped reminting its cross-signing identity on every
|
||||
restart.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Do NOT put in the announcement
|
||||
|
||||
- **Secret rooms and the cross-zone keys** (Sunken Sigil, Underforge Seal). These
|
||||
are discovery content — no command, found by walking. Announcing them deletes
|
||||
the discovery.
|
||||
- **The NPC buffs** (Misty/Arina). Hidden mechanics by design. Say the NPCs have
|
||||
arcs; do not say what befriending them *does*.
|
||||
- Numbers, rolls, and rarity math generally — house style is verbs and outcomes,
|
||||
not crunch.
|
||||
|
||||
## Watch list (first live exercise in prod)
|
||||
|
||||
Renown, the Omen, seasonal events, the Hollow King arc, the world boss and duels
|
||||
have **never fired against real players before today**. Expect the first firings
|
||||
to be the real test.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Announcement copy — your voice (draft)
|
||||
|
||||
Working-tree doc; do not commit.
|
||||
|
||||
Register: a person telling their friends what they built. No press-release
|
||||
cadence, no bold-hook-per-paragraph, no claiming things are new when they're
|
||||
expanded (houses, the town, and the story all already existed).
|
||||
|
||||
---
|
||||
|
||||
## Long version (post to the games room)
|
||||
|
||||
Big Adventure update went out. The main thing: you can play it with other people
|
||||
now.
|
||||
|
||||
`!expedition invite @user` on day one of a run and they come with you. Everyone
|
||||
buys their own supplies and takes their own turn, and enemies get more to do when
|
||||
the party's bigger, so a group of four doesn't just walk through everything. This
|
||||
was most of the work — the combat engine only ever knew how to run one player
|
||||
against one monster, so it had to be rebuilt.
|
||||
|
||||
And if nobody's around, you can hire Pete. `!expedition hire` and he'll fill
|
||||
whatever role you're short of — he fights his own turns, takes no loot and no XP,
|
||||
and files a story about it afterwards. He's one guy, so if he's already out with
|
||||
someone else you'll be told he's on assignment.
|
||||
|
||||
While I was in there: you can duel each other for a stake (`!duel @user 500`,
|
||||
nobody dies), and there's a world boss once a day that everyone chips at together
|
||||
(`!adventure worldboss`). There's also a rival called the Shadow who runs the
|
||||
dungeon whether or not you log in — `!adventure shadow` to see how you compare.
|
||||
|
||||
Houses do a bit more at the top end. Tier 3 gets you a trophy hall and a
|
||||
workshop, and resting at home now leaves you well-rested — better the higher your
|
||||
tier. Tier 4 gets you a vault that `!sell all` can't touch, and a second pet turns
|
||||
up on its own.
|
||||
|
||||
The town's grown too. You can hand items to each other with `!give`, and `!town`,
|
||||
`!graveyard` and `!rivals` will show you how everyone else is doing.
|
||||
|
||||
L20 isn't the cap anymore — XP past it turns into Renown, which shows on your
|
||||
sheet and keeps climbing. And there's a new modifier every week that changes how
|
||||
the week plays; I'll tell you what's in the air in the morning.
|
||||
|
||||
The story's been expanded into a proper campaign that runs through the zones. A
|
||||
king was emptied out so the crown could keep wearing him; he's been dead a long
|
||||
time and the debt is still accruing. `!adventure journal` to read what you've
|
||||
picked up. Misty, Robbie and Thom all have somewhere to go now, too.
|
||||
|
||||
Loot chasing got a couple of new levers: `!adventure temper` pushes a magic item
|
||||
one rarity step up at the blacksmith, and the arena runs in seasons now, with
|
||||
titles for whoever's on top when one closes.
|
||||
|
||||
Pete's reporting on all of it at news.parodia.dev/adventure — zone firsts, deaths,
|
||||
achievements — plus live beats in here. `!news` to read it, `!news optout` if you
|
||||
don't want your name in it.
|
||||
|
||||
Also `!revisit <N>` if you want to double back a room, and a pile of things that
|
||||
were quietly broken now aren't.
|
||||
|
||||
It's all live. Let me know what's broken.
|
||||
|
||||
---
|
||||
|
||||
## Short version (one-screen post)
|
||||
|
||||
Big Adventure update, and the main thing is you can play it with other people now.
|
||||
|
||||
- **Parties** — `!expedition invite @user`. Real co-op, own turns, enemies scale
|
||||
to the group. The combat engine got rebuilt for it.
|
||||
- **Hire Pete** — `!expedition hire`. He fills the role you're missing, takes no
|
||||
cut, and writes about it after.
|
||||
- **Duels** — `!duel @user <stake>`. Staked, nobody dies.
|
||||
- **World boss** — `!adventure worldboss`. One communal bout a day.
|
||||
- **The Shadow** — `!adventure shadow`. A rival who plays whether you do or not.
|
||||
- **Houses** — more at the top end: well-rested buff, a vault at T4, a second pet.
|
||||
- **The town's grown** — `!give`, `!town`, `!graveyard`, `!rivals`.
|
||||
- **Renown** — L20 isn't the cap anymore.
|
||||
- **A weekly modifier** — the world changes shape every week.
|
||||
- **The story's now a campaign** — a dead king, a crown still wearing him, a debt
|
||||
still accruing. `!adventure journal`.
|
||||
- **Tempering + arena seasons** — `!adventure temper`, `!arena`.
|
||||
- **Pete's on the beat** — news.parodia.dev/adventure. `!news`.
|
||||
|
||||
All live. Let me know what's broken.
|
||||
@@ -1,100 +0,0 @@
|
||||
# GogoBee → Appservice Auth Migration (multi-session)
|
||||
|
||||
Goal: move TwinBee's Matrix auth from the **MAS OAuth 2.0 device grant** (working
|
||||
stopgap) to the **full appservice transaction/push model** — the MAS-durable end
|
||||
state: no human login, no MFA, no consent, `as_token` never expires, scales at
|
||||
485+ rooms. The device grant stays behind `AUTH_MODE=masdevice` as instant
|
||||
rollback.
|
||||
|
||||
Why the *previous* appservice attempt failed: it was a hybrid (as_token + `/sync`),
|
||||
and Synapse forbids appservice-namespace users from `/sync`. This migration
|
||||
replaces `/sync` with Synapse→bot transaction push + MSC3202/MSC2409 E2EE
|
||||
extensions, which is the supported path in mautrix v0.28.1.
|
||||
|
||||
Keep this doc working-tree only — do NOT commit (see feedback_dont_commit_plan_mds).
|
||||
|
||||
## Key facts established (investigation, 2026-07-03)
|
||||
|
||||
- **HEAD 20d1f92 is MISLABELED**: commit msg says "device grant" but it only
|
||||
deleted registration.yaml.example; committed client.go is still the FAILED
|
||||
appservice+/sync hybrid. The real device-grant (masauth.go + client.go rewrite)
|
||||
is UNCOMMITTED working tree — that's what runs in prod. Backed up to scratchpad.
|
||||
- mautrix v0.28.1 fully supports appservice E2EE: transaction carries
|
||||
`to_device`/`device_lists`/`device_one_time_keys_count` (stable + msc2409/msc3202
|
||||
unstable field names); `CreateDeviceMSC4190`; cryptohelper `MSC4190=true` mints
|
||||
the device (no /login). HEAD's old appservice code already had auth+crypto
|
||||
correct — only the /sync event loop was wrong.
|
||||
- To-device processing gates on `Registration.EphemeralEvents` (yaml
|
||||
`receive_ephemeral: true`) — `appservice/http.go:133`.
|
||||
- Encrypted rooms: must feed `m.room.encryption`/`m.room.member` to the client's
|
||||
StateStore via `mautrix.UpdateStateStore(...)` + `mach.HandleMemberEvent`, or the
|
||||
bot sends plaintext into encrypted rooms / can't share keys.
|
||||
- Both hosts SSH-reachable: `reala@parodia.dev` (Synapse/MAS/Authentik docker),
|
||||
`reala@192.168.1.212` (millenia, bot in screen). Retired registration at
|
||||
`/home/reala/matrix/compose/synapse/gogobee-registration.yaml.retired-20260703-223425`.
|
||||
|
||||
## Decisions (user, 2026-07-03)
|
||||
|
||||
- Cutover: **AUTH_MODE flag** (keep device grant as rollback), not full replace.
|
||||
- Synapse changes: **I apply + restart, confirm right before restart**.
|
||||
|
||||
## Session 1 — bot-side code (local, no prod risk) ✅ DONE (build+vet green)
|
||||
|
||||
- [x] `Config`: AuthMode + appservice fields (RegistrationPath, ListenHost/Port,
|
||||
HomeserverDomain).
|
||||
- [x] `internal/bot/session.go`: `Session` unifying both modes (OnEventType/Run/
|
||||
Stop); `NewSession(cfg)` dispatches on AuthMode; masdevice path wraps existing
|
||||
device-grant NewClient + DefaultSyncer + sync loop (verbatim).
|
||||
- [x] `internal/bot/appservice.go`: LoadRegistration → CreateFull → BotClient;
|
||||
cryptohelper MSC4190 device create; ep.OnOTK/OnDeviceList + 9 crypto to-device
|
||||
types → crypto machine; EventEncrypted decrypt+redispatch; StateStore
|
||||
population (encryption + members) via lazy per-room resolveRoom + UpdateStateStore
|
||||
on member/encryption state; HTTP listener via as.Start() in goroutine.
|
||||
- [x] `main.go`: NewSession + sess.OnEventType + sess.Run; env AUTH_MODE,
|
||||
AS_REGISTRATION, AS_LISTEN_HOST, AS_LISTEN_PORT, HOMESERVER_DOMAIN; envInt helper.
|
||||
- [x] `.env.example` documented; go.mod tidied (zerolog now direct dep).
|
||||
- [x] `go build`/`go vet` green (CGO=1 -tags goolm).
|
||||
|
||||
New files: internal/bot/session.go, internal/bot/appservice.go. NOT yet committed.
|
||||
|
||||
### Known correctness risks to verify in Session 3
|
||||
- **resolveRoom is lazy on inbound encrypted events only.** Scheduled/broadcast
|
||||
posts into an encrypted room the bot hasn't received from yet would send
|
||||
plaintext (StateStore unresolved). Mitigation for S3: prewarm the configured
|
||||
rooms (BROADCAST_ROOMS, GAMES_ROOM, ESTEEMED_ROOM, MOD_ADMIN_ROOM, MINIFLUX_*)
|
||||
at startup, or resolve all joined rooms once. Interactive `!` commands are
|
||||
reply-driven so lazy resolve covers them.
|
||||
- Whether crypto machine successfully shares keys after ReplaceCachedMembers +
|
||||
device fetch (FetchKeys) — the make-or-break, only testable against live Synapse.
|
||||
- MSC4190 device creation must succeed against MAS-fronted Synapse (untested end
|
||||
to end; HEAD's old code used the same path but was never verified past whoami).
|
||||
|
||||
## Session 2 — Synapse infra on parodia (touches live homeserver)
|
||||
|
||||
- [ ] Restore gogobee-registration.yaml: set `url:` = bot tailnet addr, add
|
||||
`receive_ephemeral: true`, `org.matrix.msc3202: true`, keep
|
||||
`io.element.msc4190: true`, `rate_limited: false`.
|
||||
- [ ] homeserver.yaml `experimental_features`: msc2409_to_device_messages_enabled,
|
||||
msc3202_device_masquerading, msc3202_transaction_extensions,
|
||||
msc4190_device_management. Wire `app_service_config_files`.
|
||||
- [ ] Determine bot's headscale tailnet address on parodia (Synapse must reach the
|
||||
bot's ListenPort). Confirm routing.
|
||||
- [ ] Backup homeserver.yaml + registration; **confirm before restart**; restart
|
||||
matrix-synapse; check it comes back for all users.
|
||||
|
||||
## Session 3 — cutover + E2EE verification on millenia
|
||||
|
||||
- [ ] Copy registration to bot host; set AUTH_MODE=appservice + AS_* env.
|
||||
- [ ] rsync source + build in place (CGO=1 -tags goolm), restart screen session.
|
||||
Remove empty local data/gogobee.db first (empty_local_db_wipes_prod).
|
||||
- [ ] Verify: transactions arrive (listener logs), whoami OK, MSC4190 device made,
|
||||
bot responds to `!` command in a PLAINTEXT room.
|
||||
- [ ] Verify E2EE: bot decrypts an incoming encrypted-room message AND its reply
|
||||
decrypts for a human client (encrypt-out works). This is the make-or-break.
|
||||
- [ ] If broken: AUTH_MODE=masdevice, restart → back on device grant. Then triage.
|
||||
|
||||
## Cleanup (after proven)
|
||||
|
||||
- Prune stale @twinbee devices, orphan MAS client, retire masauth.go or leave
|
||||
behind the flag. Fix HEAD mislabel (the eventual commit should actually contain
|
||||
device-grant + appservice, not the stale hybrid).
|
||||
@@ -1,244 +0,0 @@
|
||||
# Bored Adventurers — autonomous expedition starts
|
||||
|
||||
**Premise.** A player who stops tending their adventurer doesn't stop having an
|
||||
adventurer. After 24h with no Adventure action, the character gets restless and
|
||||
leaves on an expedition by itself. It goes with the gear it already has and the
|
||||
cheapest supplies it can afford. If you never arm it, the results are bad — and
|
||||
that's the mechanic, not a bug.
|
||||
|
||||
## Why this is small
|
||||
|
||||
The autopilot is already almost fully autonomous. `runAutopilotWalkDriven`
|
||||
(expedition_autorun.go:168) walks rooms, drives elite and boss fights on the real
|
||||
turn engine, auto-camps, auto-harvests, rolls day rollovers, and auto-picks a
|
||||
fork after 8h of silence. Nothing in that path ever needs a human.
|
||||
|
||||
The one thing that still needs a human is the *start*: `!expedition start <zone>
|
||||
<loadout>` (dnd_expedition_cmd.go:281). So this feature is one new ticker that
|
||||
performs that start, plus a clock to decide when.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Idle clock | **Any action against Adventure**, from any interface — not Matrix chatter, not autopilot writes |
|
||||
| Threshold | 24h idle → the adventurer leaves. No warning DM. |
|
||||
| Re-trigger | After a bored run ends, another 24h of silence starts another. Forever. |
|
||||
| Zone | Easiest zone whose **level band** contains the character. Never trivial low-tier farming. |
|
||||
| "Raid-shaped" | = **multi-region** zones (underdark, dragons_lair, abyss_portal). Avoided — *unless* they're the only at-band option (L13+), then allowed. |
|
||||
| Supplies | **Lean** loadout (cheapest). Broke → no run. |
|
||||
| Gear | Never bought, never equipped. This is the whole point. |
|
||||
|
||||
## §1 — The clock: `last_player_action_at`
|
||||
|
||||
The existing timestamps are all unusable:
|
||||
|
||||
- `player_meta.last_active_at` — auto-bumped on **every character save**
|
||||
(adventure_character.go:584). The autopilot saves constantly, so a bored
|
||||
character would refresh its own idle clock and never qualify again.
|
||||
- `user_stats.updated_at` — bumped on every Matrix message in any room
|
||||
(stats.go:103). That's chat presence, not *game* action. Rejected per the
|
||||
"any action against Adventure, not just in Matrix" rule.
|
||||
- `daily_activity` / `loadAdvDailyActivity` — unified across
|
||||
`dnd_expedition_log`, so the autopilot's own walk entries count as player
|
||||
activity. Same self-refresh problem.
|
||||
|
||||
**New:** `player_meta.last_player_action_at DATETIME`, written *only* by
|
||||
`markPlayerAction(uid)` — a direct UPDATE, never routed through
|
||||
`saveAdvCharacter`, so no ticker or autopilot path can touch it. Any future
|
||||
non-Matrix entry point (web, Pete, API) calls the same helper.
|
||||
|
||||
Stamped from `AdventurePlugin.OnMessage` when the message is a real player
|
||||
action: an `!` command in `advActionCommands`, or a reply to a pending
|
||||
interaction (`p.pending`, adventure.go:889 — shop/blacksmith/pet prompts).
|
||||
|
||||
`advActionCommands` is the 47 names actually routed in `OnMessage`'s if-chain.
|
||||
Note `Commands()` (adventure.go:158) only registers **28** — it's a help
|
||||
surface, not a routing table, and is missing `expedition`, `zone`, `fight`,
|
||||
`camp`, `extract`, `resume`, and more. A test greps the `p.IsCommand(ctx.Body,
|
||||
"…")` literals out of adventure.go and asserts each one is in the set, so a
|
||||
future command can't silently fall out of the clock.
|
||||
|
||||
## §2 — Zone pick: `pickBoredomZone(level, uid)`
|
||||
|
||||
`zonesForLevel` is wrong for this: it filters on **tier only** and ignores the
|
||||
`LevelMin`/`LevelMax` fields that already exist on `ZoneDefinition` — which is
|
||||
why a level-1 player can currently walk into a T3 zone. The boredom picker uses
|
||||
the bands instead.
|
||||
|
||||
1. Candidates = zones where `LevelMin <= level <= LevelMax`.
|
||||
2. Prefer **non**-multi-region (`IsMultiRegionZone`, dnd_expedition_region.go:135).
|
||||
Among those, lowest `Tier` — the "easiest at-level" rule.
|
||||
3. Ties broken by **least-recently-started by this player**, so a bored
|
||||
adventurer rotates zones instead of grinding one forever.
|
||||
4. If step 2 is empty, retry allowing multi-region. This only bites at L13+,
|
||||
where `dragons_lair` and `abyss_portal` are the only at-band zones and both
|
||||
are multi-region.
|
||||
5. Still empty → no run.
|
||||
|
||||
Worked examples:
|
||||
|
||||
| Level | At-band | Picked |
|
||||
|---|---|---|
|
||||
| 5 | forest_shadows, sunken_temple (T2), manor, underforge (T3) | a T2 zone |
|
||||
| 10 | underdark (multi), feywild_crossing | feywild_crossing |
|
||||
| 12 | underdark (multi), feywild (T4), dragons_lair (multi, T5) | feywild_crossing |
|
||||
| 15 | dragons_lair (multi), abyss_portal (multi) | dragons_lair — fallback fires |
|
||||
|
||||
## §3 — The run
|
||||
|
||||
Ticker `expeditionBoredomTicker`, 30m interval, skips the ambient quiet window.
|
||||
|
||||
Candidates: `player_meta` rows, `alive = 1`, `COALESCE(last_player_action_at,
|
||||
created_at) < now-24h`, and `last_boredom_at` NULL or older than the 24h
|
||||
cooldown. (COALESCE on `created_at` so a character made and never played still
|
||||
qualifies, but not on its first tick.)
|
||||
|
||||
Per-user skips, all reusing existing guards: resting lockout, seated on someone
|
||||
else's party, active expedition, resumable (extracted, pending `!resume`)
|
||||
expedition, active zone run, active combat session.
|
||||
|
||||
CAS-claim `last_boredom_at` **before** starting, so a crash mid-start can't loop.
|
||||
Then: `loadoutPurchase(zone.Tier, LoadoutLean)` → balance check → shared
|
||||
`beginExpedition` helper → DM.
|
||||
|
||||
A zero-supply start is impossible (`SupplyPurchase.Validate`,
|
||||
dnd_expedition_supplies.go:270, hard-rejects it), so the cheapest bored run costs
|
||||
50 coins at T1 and 250 at T5. Can't afford it → no run, one DM (rate-limited by
|
||||
the cooldown we already claimed).
|
||||
|
||||
## §4 — Refactor: `beginExpedition`
|
||||
|
||||
`expeditionCmdStart` is prompt-layer (:282–309) followed by a reusable
|
||||
transaction (:369–424): balance check, debit, holiday/omen freebies,
|
||||
`startExpedition`, `ensureRegionRun`, log, refund-on-failure. Extract that second
|
||||
half into `beginExpedition(uid, c, zone, purchase, reason)` so the command and
|
||||
the boredom ticker share one code path and one refund story.
|
||||
|
||||
## §5 — Why the results will be bad (no work required)
|
||||
|
||||
This all already exists; the feature just points it at neglected characters:
|
||||
|
||||
- The autopilot **never buys or equips anything** — verified, no `buy`/`equip`/
|
||||
`Debit` calls anywhere in expedition_autorun.go, expedition_autocamp.go, or
|
||||
dnd_expedition_autopilot_harvest.go. Stale gear stays stale.
|
||||
- Lean supplies deplete fast → **Rationing** (−1 attack/skill) at 25% →
|
||||
**Severe** (−2, long rests blocked) at 10% → **Starvation** at 0, which forces
|
||||
an extract with a **20% coin tax** on the haul (dnd_expedition_cycle.go:140).
|
||||
- The boss-safety gate holds the autopilot out of boss rooms below 80% HP, so an
|
||||
under-geared character stalls, camps, and burns supplies it doesn't have.
|
||||
|
||||
The neglected adventurer grinds mediocre, half-starved runs on rusting gear and
|
||||
comes home taxed. Exactly the intent.
|
||||
|
||||
## §6 — Streaks: no credit for a run you didn't ask for
|
||||
|
||||
Bored runs write `dnd_expedition_log` entries, which `loadAdvDailyActivity`
|
||||
counts as player activity — so without care a bored run would **suppress the
|
||||
idle-shame DM and preserve the daily streak** for someone who hasn't shown up in
|
||||
weeks.
|
||||
|
||||
Two halves, and only one needed fixing:
|
||||
|
||||
- **Bumping** the streak was already safe. `engaged` reduces to
|
||||
`LastActionDate == today|yesterday` (the `CombatActionsUsed`/
|
||||
`HarvestActionsUsed` clauses are dead — nothing in the tree increments them),
|
||||
and `LastActionDate` is only set by `markActedToday`, which is called only
|
||||
from real player commands. `beginExpedition` deliberately does not call it.
|
||||
- **Shielding** was not. Both hold branches in `midnightReset` (the activity
|
||||
oracle, and the active-expedition safety net) would spare an absent player.
|
||||
|
||||
Fixed with `dnd_expedition.boredom` + `isBoredomDriven(uid, now)`, which requires
|
||||
*both* halves: the player hasn't acted in 24h **and** their most recent
|
||||
expedition is one the adventurer started for itself. A manually-started
|
||||
expedition still holds the streak while the autopilot walks it — that behavior is
|
||||
untouched. Reading the *most recent* expedition rather than the active one keeps
|
||||
it honest on the night after a bored run ends, when its logs would otherwise
|
||||
still read as player activity.
|
||||
|
||||
## §7 — Robbie
|
||||
|
||||
Robbie sweeps every non-slotted inventory item daily (ores, fish, junk,
|
||||
treasure — `robbieQualifyingItems` takes all of them unconditionally) and pays
|
||||
`Value / 4`. The autopilot deposits harvested loot straight into
|
||||
`adventure_inventory` (dnd_expedition_harvest.go:478, dnd_zone_loot.go), so:
|
||||
|
||||
**Robbie funds the boredom loop.** Bored run → auto-harvested junk → Robbie
|
||||
converts it to coins → pays for the next lean pack → forever. The "they go broke
|
||||
and stop" poverty floor does not exist. Abandoned characters are perpetual
|
||||
motion, and that is the accepted design.
|
||||
|
||||
What was *not* accepted was the noise: Robbie posts a public room announcement
|
||||
per visit, so every abandoned character would file a daily bulletin about
|
||||
somebody who stopped playing weeks ago. He now visits and pays silently for idle
|
||||
players (adventure_robbie.go), gated on the same clock.
|
||||
|
||||
## §8 — The bug this nearly shipped with
|
||||
|
||||
`playerIsIdle` originally scanned `COALESCE(last_player_action_at, created_at)`
|
||||
straight into a `*time.Time`, and `lastExpeditionByZone` scanned
|
||||
`MAX(start_date)`. **modernc.org/sqlite rebuilds a `time.Time` from the column's
|
||||
declared type, and both `COALESCE()` and `MAX()` erase it** — the value comes
|
||||
back a string and the Scan fails.
|
||||
|
||||
`lastExpeditionByZone` failed loudly. `playerIsIdle` failed *open*: an unreadable
|
||||
row counts as idle, so it returned `true` for every player alive, including one
|
||||
who had acted an hour earlier. Shipped, that would have marched the entire server
|
||||
into dungeons at once and silenced Robbie for everybody.
|
||||
|
||||
The unit tests missed it at first because they ran against empty tables, so the
|
||||
scan never executed. Both now select the declared columns and fold in Go.
|
||||
|
||||
Note the asymmetry: `loadBoredomCandidates` uses `COALESCE` too and is fine —
|
||||
it's evaluated *inside* SQL and never scanned. Only Go-side scans through an
|
||||
aggregate or COALESCE lose the type.
|
||||
|
||||
## §9 — Status
|
||||
|
||||
Built, vetted, full suite green. **Not deployed, not committed.** Working tree
|
||||
carries: db.go (3 columns), expedition_boredom.go (new), expedition_boredom_test.go
|
||||
+ expedition_boredom_clock_test.go (new), dnd_expedition_cmd.go (beginExpedition
|
||||
extraction), adventure.go (clock stamp + ticker), adventure_scheduler.go (idle
|
||||
reaper gate), adventure_robbie.go (silent for idle), twinbee_expedition_flavor.go
|
||||
(ExpeditionBoredomStart).
|
||||
|
||||
## §10 — Verified end-to-end against prod data
|
||||
|
||||
`internal/plugin/exercise_boredom_prod_test.go` (build tag `prodexercise`), run
|
||||
against a throwaway copy of the live DB:
|
||||
|
||||
```
|
||||
GOGOBEE_PROD_DB_DIR=<dbcopy> go test -tags prodexercise -run TestExerciseBoredomProd -v ./internal/plugin/
|
||||
```
|
||||
|
||||
PASS. What it actually proved, on real characters with real gear:
|
||||
|
||||
- **The clock gates the sweep.** A control player stamped as having acted an hour
|
||||
ago was not a candidate and was not charged; every player who departed passed
|
||||
`playerIsIdle`. This is the assertion that would have caught the COALESCE
|
||||
fail-open bug (§8), which is why the control arm is not optional.
|
||||
- **Real departures.** 3 of 5 candidates left. `@holymachina` (L20 cleric) →
|
||||
`dragons_lair`, `@quack` (L4 mage) → `forest_shadows`, `@camcast` (L1) →
|
||||
`crypt_valdris`. The other two stayed home for the right reasons and the test
|
||||
names them: one has no character, one never finished creation.
|
||||
- **L20 into the raid, as designed.** `dragons_lair` is the only zone in
|
||||
`@holymachina`'s band, so the fallback fired and the departure DM carried the
|
||||
raid warning. Exactly the decision that was locked.
|
||||
- **It bought nothing.** Coins debited equal the lean pack to the coin (250 at
|
||||
T5, 50 at T1/T2), and the equipment fingerprint is byte-identical across the
|
||||
run.
|
||||
- **Flagged and cooled.** `boredom = 1` on every row, `isBoredomDriven` true, and
|
||||
a second tick 30 minutes later charged nobody a second time.
|
||||
- **The autopilot walks it.** Rooms accumulate across segments.
|
||||
|
||||
And it drew blood on the first run: **`@quack` (L4 mage, stale gear) died four
|
||||
rooms in.** That is the mechanic, working, on the very first exercise. Death is
|
||||
`Kill()` — `alive = 0`, 6h respawn, auto-revived by the scheduler ticker
|
||||
(adventure_scheduler.go:72), and `loadBoredomCandidates` requires `alive = 1`, so
|
||||
a dead adventurer sits out its six hours and then gets restless again. Death
|
||||
pauses the loop; it doesn't end it.
|
||||
|
||||
## §11 — Status
|
||||
|
||||
Built, vetted, full suite green, exercised end-to-end against prod data.
|
||||
**Not deployed, not committed.**
|
||||
@@ -1,787 +0,0 @@
|
||||
# Code review follow-ups — `n1-restoration` (2026-07-10)
|
||||
|
||||
Review scope: `git diff main...HEAD`, 99 files / ~16.7k lines (N1, N2, R1, R2, N3 P1–P7).
|
||||
Method: 8 finder angles → dedup → 1 verifier per correctness candidate.
|
||||
Baseline before and after: `go build`, `go vet`, `go test ./internal/...` all green.
|
||||
|
||||
Working-tree doc — **do not commit** (per project convention on `gogobee_*.md`).
|
||||
|
||||
**Status.** Fixes 1–5 shipped in `1f21156`. Deferred items **A** and **B** fixed
|
||||
2026-07-10 (`d76c63b`); A turned out to have a deeper root cause and surfaced
|
||||
three new findings, **M**, **N** and **O**. Item **C** fixed 2026-07-10 — its
|
||||
premise was wrong and the bug underneath it was worse (the seven-day resume
|
||||
window was never enforced; see below). Item **M** fixed 2026-07-10 — two of its
|
||||
three claims were wrong (the mage hooks *do* run on the turn path); the one real
|
||||
defect, Grim Harvest, had a different cause than recorded. Items **D**, **I**,
|
||||
**L**, **N** fixed. Item **K** verified 2026-07-10 — the anchor gap is a real,
|
||||
confirmed narrowing, left as an owner design call (no code change). Item **H**
|
||||
measured 2026-07-10 and **declined** — its "per-message cascade" premise was
|
||||
overstated, the hot path is already optimized, and the proposed
|
||||
`MessageContext` thread-through is disproportionate churn with a staleness
|
||||
regression surface.
|
||||
|
||||
**All correctness work is committed.** Everything remaining is deliberate, not a
|
||||
defect: **E** (intentional, roster-size death rule), **F** (`grantAutorunGrace`
|
||||
stopgap, deferred to R5 by design), **G** (party-combat DB chatter — a perf
|
||||
refactor whose only safe fix is a fight-lifetime roster cache, i.e. the same
|
||||
staleness surface H was declined for; deferred), **H** (declined, above),
|
||||
**J** (`!sell` anchor — PLAUSIBLE, presence == any chat, left per convention),
|
||||
**K** (owner design call, above), **O** (already fixed as a side-effect of A;
|
||||
only the sim re-baseline is pending, which is operational).
|
||||
|
||||
**Method note.** Both C and M were mis-diagnosed in the same way: the finder read
|
||||
one call site, concluded "this is the only caller", and wrote it down. Verify the
|
||||
callers before planning the fix.
|
||||
|
||||
**Third pass (2026-07-10, `88c5fcd`).** `/code-review high --fix` over the pushed
|
||||
follow-up stack (`git diff origin/main...HEAD`, 7 commits). Five fixes shipped;
|
||||
they harden the item-C extraction work and the item-N Misty ordering rather than
|
||||
opening new ground. See "Third pass" below.
|
||||
|
||||
---
|
||||
|
||||
## Before you commit: split the formatting from the fixes
|
||||
|
||||
A repo-wide `gofmt -w ./internal ./cmd` ran after the review. It reformatted
|
||||
**105 files that have nothing to do with these findings** — they were already
|
||||
non-conforming on `main` (struct-field alignment, mostly), and gofmt is
|
||||
semantics-preserving, so nothing changed behaviourally.
|
||||
|
||||
Net state: **117 files dirty, only 13 carry review fixes.** Commit them apart, or
|
||||
the fixes become unreviewable inside a wall of realignment.
|
||||
|
||||
The 13 fix-bearing files:
|
||||
|
||||
```
|
||||
internal/plugin/adventure.go * internal/plugin/dnd_expedition_supplies.go *
|
||||
internal/plugin/adventure_arena_season.go internal/plugin/dnd_zone_cmd.go *
|
||||
internal/plugin/combat_cmd.go internal/plugin/expedition_party.go
|
||||
internal/plugin/combat_party_turn.go internal/plugin/expedition_party_cmd.go
|
||||
internal/plugin/dnd_expedition.go * internal/plugin/expedition_party_resolve.go
|
||||
internal/plugin/dnd_expedition_combat.go internal/plugin/zone_combat_party.go
|
||||
internal/plugin/combat_engine_party_test.go
|
||||
```
|
||||
|
||||
Plus one new file: `internal/plugin/zone_combat_party_casualty_test.go`.
|
||||
|
||||
The four marked `*` carry **both** a fix and gofmt realignment
|
||||
(`adventure.go`, `dnd_expedition.go`, `dnd_expedition_supplies.go`,
|
||||
`dnd_zone_cmd.go`), so a clean split needs `git add -p` on those rather than a
|
||||
straight per-file split. The other nine are fix-only and can be staged whole.
|
||||
|
||||
---
|
||||
|
||||
## Fixed in this pass
|
||||
|
||||
| # | File | Defect |
|
||||
|---|------|--------|
|
||||
| 1 | `combat_party_turn.go` | Settle-to-terminal dropped the entire close-out |
|
||||
| 2 | `expedition_party_cmd.go` | Party member permanently soft-locked on leader extract |
|
||||
| 3 | `expedition_party_cmd.go` | Lost update on the shared supply pool |
|
||||
| 4 | `dnd_expedition_combat.go` | Elite harvest interrupt granted no elite loot |
|
||||
| 5 | `adventure_arena_season.go` | Transient failure permanently lost a season crown |
|
||||
|
||||
Plus cleanups: deleted dead `partySurvivors`; collapsed the `zoneCombatRoster`
|
||||
alias into `fightRoster`; `partyCasualtyLine` now uses `joinNames`; consolidated
|
||||
four copies of the expedition column projection into `expeditionSelectCols`;
|
||||
corrected two false doc comments on `applyDailyBurn`/`applyDailyBurnP`; `replyDM`
|
||||
no longer sends a blank DM.
|
||||
|
||||
### 1. Close-out dropped when the settle turns lethal — the worst one
|
||||
|
||||
`beginCombatTurn` calls `settleCombatSession` to resolve a phase the engine owes
|
||||
(a fight parked mid enemy-turn after a restart). That settle can end the fight.
|
||||
The old code then saw `!sess.IsActive()` and returned "you're not in a fight."
|
||||
|
||||
The terminal status was already persisted, so nothing ever paid the party out:
|
||||
no XP, no loot, no `markAdventureDead`, no `abandonZoneRun`, no
|
||||
`forceExtractExpeditionForRunLoss`. The reaper cannot rescue it either —
|
||||
`listExpiredCombatSessions` filters `status = 'active'`, and the session is now
|
||||
terminal. The win or the death simply evaporates.
|
||||
|
||||
The `!fight` start path and the reaper both already do settle-then-`closePartyRound`;
|
||||
`beginCombatTurn` was the one settle site that skipped it. Now it closes out and
|
||||
announces, matching them.
|
||||
|
||||
### 2. Member soft-locked when the leader extracts and walks away
|
||||
|
||||
`seatedExpeditionFor` (the guard) spans `status IN ('active','extracting')`.
|
||||
`expeditionForMember` (what `!expedition leave` resolved through) filtered
|
||||
`status = 'active'` only. During the leader's 7-day extracting limbo a member was
|
||||
therefore refused any new adventure by the guard, and told "No active expedition"
|
||||
by the very command the guard pointed them at. Nothing sweeps stale `extracting`
|
||||
rows, and only the *leader* can trigger the transition that clears them — so if
|
||||
the leader quit, the member was stuck forever with no self-service recovery.
|
||||
|
||||
`!expedition leave` now resolves the seat through `seatedExpeditionFor`, so the
|
||||
exit sees every state the gate sees. (`seatedExpeditionFor` already excludes
|
||||
leaders, so they still fall through to the `!extract` message.)
|
||||
|
||||
### 3. Lost update on the shared supply pool
|
||||
|
||||
`updateSupplies` rewrites `supplies_json` wholesale. `expeditionCmdAccept` folded
|
||||
a new member's packs onto an `exp` snapshot read ~60 lines earlier, with no lock.
|
||||
Handlers run one goroutine per event (mautrix `AsyncHandlers`, never overridden),
|
||||
so two invitees accepting at once genuinely interleave: both read pool `P`, one
|
||||
writes `P+a`, the other `P+b` — a member's packs vanish, or spent SU is
|
||||
resurrected by the day-burn tick.
|
||||
|
||||
Note `advUserLock` cannot fix this: it is keyed by sender, so two members take
|
||||
two different mutexes. Added `advExpeditionLock(expID)` and re-read the pool
|
||||
under it. **This closes accept-vs-accept only** — see deferred item B.
|
||||
|
||||
### 4. Elite harvest interrupt paid standard loot
|
||||
|
||||
`runHarvestInterrupt` computed `elite := kind == InterruptElite`, used it to pick
|
||||
an elite enemy and to pick elite narration, then passed a hardcoded `false` as
|
||||
`isElite` to `closeOutZoneWin`. Since `dropZoneLoot` gates on `isBoss || isElite`,
|
||||
beating an elite interrupt skipped the masterwork roll and used the 1.0 standard
|
||||
treasure weight instead of 2.0 — while the identical elite fought via `!zone`
|
||||
paid out correctly.
|
||||
|
||||
Now passes the live `elite`. The param only flows into `dropZoneLoot`, so this
|
||||
adds the masterwork roll and doubles treasure weight for surviving seats; threat,
|
||||
XP, and narration are untouched. A small reward buff on a fight players already
|
||||
win, consistent with lifting trailers rather than nerfing.
|
||||
|
||||
### 5. A failed season crown was lost permanently, not retried
|
||||
|
||||
`arenaSeasonRollover` logged-and-`continue`d when `recordArenaSeasonTitle`
|
||||
failed, then called `db.MarkJobCompleted` unconditionally. `JobCompleted`
|
||||
short-circuits every future run for that quarter, so a transient SQLite `BUSY`
|
||||
meant the crown was never awarded — ever.
|
||||
|
||||
`recordArenaSeasonTitle` is idempotent (`arena_season_titles` has
|
||||
`PRIMARY KEY (season, kind)`, insert is `ON CONFLICT DO NOTHING`) and a past
|
||||
season's data is frozen, so retrying is safe. The job is now marked complete only
|
||||
when no crown failed. The "no entrants" path still closes the season correctly.
|
||||
|
||||
---
|
||||
|
||||
## Fixed in the second pass (2026-07-10) — items A and B
|
||||
|
||||
### A. Turn-based combat skipped achievements and post-combat subclass state
|
||||
|
||||
The root cause was one level deeper than this doc originally recorded, and it is
|
||||
worth writing down because the surface symptom was misleading.
|
||||
|
||||
`buildZoneCombatants` used to call `applyArmedAbility`, which applies an ability's
|
||||
mods **and then clears `c.ArmedAbility` and saves the sheet**. The turn engine
|
||||
calls that same builder again on *every* `!attack` / `!cast` / `!consume`. So in a
|
||||
turn-based fight:
|
||||
|
||||
- Round 1's build fired the ability, spent the resource, cleared the arm flag.
|
||||
- Round 2+ rebuilt the character, found `ArmedAbility == ""`, and produced a
|
||||
combatant with **none of the ability's mods**.
|
||||
|
||||
A Berserker paid stamina and got exactly one round of `BerserkerRage`,
|
||||
`RageMeleeDmg`, `PhysicalResistRage`, `FrenzyDmgBonus`. Every entry in
|
||||
`dndActiveAbilities` had the same shape. `mods.BerserkerRage` was not merely
|
||||
unread at close-out — by then it no longer existed.
|
||||
|
||||
Fixed by splitting arming into its two halves:
|
||||
|
||||
- `consumeArmedAbility(c)` — the mutation. Disarms, saves, returns the id. Runs
|
||||
**once**, at fight start.
|
||||
- `applyAbilityByID(c, id, mods)` — pure. No DB write, no disarm. Safe on every
|
||||
rebuild. (No ability's `Apply` writes to the character, so this is genuinely pure.)
|
||||
- `armAbilityForFight(c, mods)` — consume + apply, for the auto-resolve callers
|
||||
that build and fight in one breath.
|
||||
|
||||
`buildZoneCombatants` now takes the already-consumed `armed` id and re-applies it.
|
||||
The id rides on `ActorStatuses.ArmedAbility`, seeded per seat at fight start
|
||||
(`seedActorOneShots`), so `partyCombatantsForSession` reproduces the ability on
|
||||
every rebuild and the close-out can still see that a rage fired.
|
||||
|
||||
On top of that, the close-out itself: `postCombatBookkeeping(...)` in
|
||||
`combat_bridge.go` now carries the achievements + subclass persistence, and all
|
||||
four close-outs route through it — `runDungeonCombat`, `runZoneCombatRoster`,
|
||||
`finishCombatSession`, `finishPartyCombatSession`. The turn-based pair reach it
|
||||
via `postCombatBookkeepingForSeat`, which rebuilds the `CombatResult` the hooks
|
||||
need from the persisted session (`seatCombatResult`) and re-derives the
|
||||
fight-start mods (`seatFightStartMods`).
|
||||
|
||||
It fires on **every** terminal status, not just a win — a Berserker who rages and
|
||||
loses is still exhausted, which is what auto-resolve always did.
|
||||
|
||||
Also fixed in passing: `buildFightSeats` and `runZoneCombatRoster` consumed the
|
||||
armed ability *before* the checks that could sit a seat out, so a downed member
|
||||
was disarmed for a fight they never joined. The refusals now run first.
|
||||
|
||||
**Balance note.** Turn-based Berserkers now keep rage for the whole fight instead
|
||||
of one round. That is a buff, but it is the buff the player already paid for.
|
||||
The class-balance corpus measures the auto-resolve path, so it is unmoved.
|
||||
|
||||
New tests: `internal/plugin/combat_armed_ability_test.go` (9 cases) —
|
||||
purity/repeatability of `applyAbilityByID`, once-only consume, rage surviving
|
||||
rebuilds, the sat-out member keeping their ability, per-seat statuses isolation,
|
||||
and exhaustion on both a manual win and a manual loss.
|
||||
|
||||
The Grim Harvest half of this item was closed separately — see item **M**.
|
||||
|
||||
### B. The other six supply writers raced
|
||||
|
||||
All six now go through `withExpeditionSupplies` (`dnd_expedition.go`), which takes
|
||||
`advExpeditionLock(expID)`, re-reads the expedition, hands the closure the fresh
|
||||
row, and persists what it returns:
|
||||
|
||||
`dnd_expedition_cycle.go` (`nightRolloverBurn`, forage + burn in one write),
|
||||
`dnd_expedition_milestone.go` (`grantTwoWeeksCache`),
|
||||
`dnd_expedition_region_cmd.go` (`advanceToNextRegion` transit burn),
|
||||
`dnd_expedition_camp.go` (`campPitch`), `expedition_autocamp.go`
|
||||
(`pitchAutopilotCamp`), `expedition_ambient.go` (pack-rat drain).
|
||||
|
||||
Fix #3's hand-rolled lock in `expeditionCmdAccept` was folded onto the same
|
||||
helper. Seven call sites, no nesting — verified none of the closure callees
|
||||
re-enter the lock (`sync.Mutex` is not reentrant).
|
||||
|
||||
`expedition_sim.go:1421` is left alone on purpose: the sim is single-threaded and
|
||||
takes no plugin locks.
|
||||
|
||||
The atomic-`json_set`-delta alternative is still the better long-term shape (it
|
||||
would drop the lock entirely) but it couples `updateSupplies` to the blob format.
|
||||
|
||||
---
|
||||
|
||||
## Third pass (2026-07-10, `88c5fcd`) — `/code-review high` on the follow-up stack
|
||||
|
||||
Reviewed `git diff origin/main...HEAD` (the 7 pushed follow-up commits) with 8
|
||||
finder angles → dedup → 1 verifier per candidate. The removed-behavior angle came
|
||||
back empty: the close-out refactor (item D), `parseMenuIndex` (item I), the
|
||||
GrimHarvest stash (item M), and the `endRunOnLoss`/`applyOwnerWinEffects`/
|
||||
`grantSeatWinXP` extractions all verified behavior-preserving. Findings clustered
|
||||
in the new item-C extraction guard, plus one ordering nuance in the item-N Misty
|
||||
wiring. Five fixed:
|
||||
|
||||
1. **The extraction guard fell open on a DB error.** `expeditionCmdStart`'s
|
||||
resumable-guard did `switch n, err := partySize(pending.ID)` and gated the
|
||||
party-blocking refusal on `case err == nil && n > 1`. A transient roster-read
|
||||
error skipped both cases, fell through, and started a new expedition on top of
|
||||
the still-seated party — the exact orphaning the guard exists to prevent, in
|
||||
the one situation (a DB hiccup on the roster read) where it matters. Now checks
|
||||
`extractionLapsed` first (pure, no DB call on the reap path) and treats a
|
||||
`partySize` error as "assume occupied → refuse". Also drops the wasted `COUNT`
|
||||
the old switch-init ran on the lapsed-reap path.
|
||||
|
||||
2. **The start-path lapsed reap unseated members silently.** When a leader with a
|
||||
lapsed party extraction ran `!expedition start`, the reap called
|
||||
`completeExpedition(...Failed)` — which frees the roster — with no DM, unlike
|
||||
the hourly sweeper and `!expedition abandon`, which both notify. Extracted
|
||||
`reapLapsedExtraction(e)` (reap + notify the audience) as the single
|
||||
reap-with-notify path; the sweeper and the start-path reap both route through
|
||||
it, so a member is never silently unseated by whichever path reaches the row
|
||||
first.
|
||||
|
||||
3. **Misty's crowd swung before the concentration pulse.** Item N's new
|
||||
`stepRoundEnd` seat-loop ran `seatEndOfRound` (crowd revenge, which can end the
|
||||
fight) *before* the pre-existing concentration-aura tick. Concentration is
|
||||
turn-engine-only (no auto-resolve counterpart), so this was a within-engine
|
||||
ordering call, not a cross-engine divergence — but a caster whose lingering
|
||||
aura would kill the enemy that round could be dropped by crowd revenge first,
|
||||
contradicting the concentration block's own "a lethal pulse settles the fight"
|
||||
intent. Moved the Misty seat-loop to *after* the concentration tick: a lethal
|
||||
pulse now wins via `finish(CombatStatusWon)` before the end-of-round crowd
|
||||
swing; on any round the pulse doesn't end the fight, both procs fire exactly as
|
||||
before. (Owner-approved reorder — it was reported as a balance call and the
|
||||
answer was "move it".)
|
||||
|
||||
4. **Fourth copy of the event-log scan.** `seatCombatResult` hand-rolled a
|
||||
`misty_heal` scan loop. Promoted the test-only `hasAction(events, action)`
|
||||
helper to production (`combat_session_build.go`), used it there, and removed the
|
||||
duplicate test definition.
|
||||
|
||||
5. **New `dnd_`-prefixed file.** Renamed `dnd_expedition_extract_sweep_test.go` →
|
||||
`expedition_extract_sweep_test.go`, per the no-new-`dnd_`-names rule (the diff's
|
||||
three other new test files already avoided the prefix).
|
||||
|
||||
**Reported, not fixed** (deliberate):
|
||||
|
||||
- **Misty ordering** was the only correctness-shaped finding; the other angles
|
||||
agreed the stack was clean. Two lower items were left: `dnd_setup.go`'s
|
||||
respec/wipe disbands an extracted party with no member DM — but the disband is
|
||||
intended (item C hole #3) and the missing DM is outside the reviewed lines; and
|
||||
`abandonExpedition` re-queries the row the caller already resolved — the
|
||||
divergence a finder posited isn't reachable (the extracted-row branch only runs
|
||||
when no active row exists), so the extra indexed SELECT was left alone.
|
||||
|
||||
Full plugin suite green before and after. No auto-resolve path touched, so the
|
||||
class-balance corpus and `combat_characterization.golden` do not move.
|
||||
|
||||
---
|
||||
|
||||
## Deferred — real, not fixed
|
||||
|
||||
### ~~M. Mage subclass spell hooks never run in turn-based combat~~ — TWO-THIRDS MIS-STATED. Fixed 2026-07-10.
|
||||
|
||||
**The premise was wrong.** `applyMageSubclassSpellHooks` has three non-test
|
||||
callers, not one. `resolveTurnSpell` (`dnd_spell_combat.go:341`) has called it
|
||||
since Phase 13 shipped per-round casting (`5cd343a`, 2026-05-14).
|
||||
|
||||
So on the turn-based surface:
|
||||
|
||||
- **Empowered Evocation** (L7, +INT to evocation damage) — *always worked.*
|
||||
- **Evocation Overchannel** (L10, ×1.5 on a 1st–5th-level slot) — *always worked.*
|
||||
|
||||
Both only move `mods.SpellPreDamage`, which `resolveTurnSpell` returns as
|
||||
`out.EnemyDamage`. Nothing was lost.
|
||||
|
||||
**Grim Harvest was the only real defect**, and its cause was narrower than "the
|
||||
hooks don't run": the hooks ran, wrote `mods.GrimHarvestSlot` — and
|
||||
`resolveTurnSpell` dropped that local `CombatModifiers` on the floor, because
|
||||
`turnSpellOutcome` had no field to carry it out. A Necromancy Mage who killed
|
||||
with a spell in a manual fight never healed.
|
||||
|
||||
The stash cannot ride on fight-start mods the way it does in auto-resolve: the
|
||||
spell is cast *mid*-fight, and the turn engine rebuilds its combatants every
|
||||
round. So it rides where every other mid-fight fact rides — the casting seat's
|
||||
`ActorStatuses`:
|
||||
|
||||
1. `turnSpellOutcome` gained `GrimHarvestSlot` / `GrimHarvestNecrotic`.
|
||||
2. `castActionForSeat` parks them on `actorStatusesPtr(seat)` when a cast lands.
|
||||
`snapshotActor` starts from the prior snapshot, so they survive `commit()`
|
||||
untouched, exactly as `ArmedAbility` does.
|
||||
3. `seatFightStartMods` reads them back at close-out, in place of the comment
|
||||
that used to explain why they were permanently zero.
|
||||
|
||||
**And the killing-blow check was wrong for this surface.** `grimHarvestHeal`
|
||||
scanned for the **first** `spell_cast` event and refused the heal if the enemy
|
||||
survived it. Auto-resolve casts once, pre-combat, so first == last there; a
|
||||
turn-based mage casts every round, so an opening cantrip that left the enemy
|
||||
standing vetoed the heal the round-3 killing spell had earned. It now scans for
|
||||
the **last** `spell_cast` — provably identical on the auto-resolve path, which is
|
||||
why the golden corpus does not move.
|
||||
|
||||
Each damaging cast overwrites the stash, so it always describes the seat's most
|
||||
recent landed spell — the only one that can have been lethal. A miss stashes
|
||||
nothing, and a stale stash is inert because the last `spell_cast` event then
|
||||
shows the enemy alive.
|
||||
|
||||
**Balance note.** This is a caster buff on the manual surface — 2×/3×/4× slot
|
||||
level in HP, once, on a spell kill, for L5+ Necromancy Mages. It is the buff the
|
||||
subclass was written to have and auto-resolve already paid out. Consistent with
|
||||
the standing "lift trailers" stance. The class-balance corpus measures
|
||||
auto-resolve and is unmoved.
|
||||
|
||||
New tests: `internal/plugin/combat_grim_harvest_turn_test.go` (7 cases) — the
|
||||
stash surviving `resolveTurnSpell`, nobody-else-stashes, last-cast-not-first,
|
||||
weapon-kill-after-a-spell, the `snapshotActor` round-trip, the end-to-end heal at
|
||||
`postCombatBookkeepingForSeat`, and per-seat isolation in a party fight.
|
||||
|
||||
### N. The NPC procs do not exist in the turn engine (RE-VERIFIED 2026-07-10 — conclusion holds, evidence corrected, scope wider) — the debuff exploit fixed 2026-07-10.
|
||||
|
||||
**Verified before planning**, after items C and M both turned out to rest on a
|
||||
bad "only one caller" claim. This one survives, but two of its three sentences
|
||||
did not.
|
||||
|
||||
**The `CrowdRevengeProc` exploit is now closed** (see the fix note at the end of
|
||||
this item). Closing it pulled `MistyHealProc` in with it: both are round-end
|
||||
effects, and the honest hook runs the pair rather than the debuff alone — a
|
||||
one-sided hook would have been a second parallel sibling of the kind item D
|
||||
warns about. So Misty's **heal** is now live on the turn path too. That is a
|
||||
player-favourable discovery mechanic and fits the "lift trailers" stance, so it
|
||||
ships together; only `SniperKillProc` stays turn-dead, because it is a
|
||||
pre-combat one-shot with no round-end seam to hang on.
|
||||
|
||||
**Correction 1 — the location was wrong.** `MistyHealProc` is not read inside
|
||||
`simulatePartyWithRNG`. It is read at `combat_engine_party.go:520` inside
|
||||
`endOfRoundForSeat`, a *helper*, which is exactly the shape that made C and M
|
||||
wrong. So the question had to be asked properly: `endOfRoundForSeat` has one
|
||||
caller, `simulatePartyRound` (`:418`), which is the auto-resolve round. The turn
|
||||
engine runs its own `stepRoundEnd` and never calls it. Conclusion stands.
|
||||
`SniperKillProc` (`:102`, genuinely in `simulatePartyWithRNG`, a pre-combat
|
||||
one-shot) has no turn-engine counterpart either.
|
||||
|
||||
**Correction 2 — the procs are built, then ignored.** `buildZoneCombatants` calls
|
||||
`DerivePlayerStats` (`combat_session_build.go:61`), which sets
|
||||
`mods.MistyHealProc` / `mods.SniperKillProc` (`combat_stats.go:180,184`) on every
|
||||
turn-based combatant. The mods are present on the struct every single round. No
|
||||
turn-engine code path reads either field. It is a dangling write, not a missing
|
||||
build.
|
||||
|
||||
**Correction 3 — there is a third proc, and it cuts the other way.**
|
||||
`CrowdRevengeProc` (`:473`) sits in the same `endOfRoundForSeat` and is equally
|
||||
absent from the turn engine. That one is the Misty **debuff**. So the asymmetry
|
||||
is not "manual fights are worse": a buffed player loses their buff by fighting
|
||||
manually, and a debuffed player *escapes their debuff* by doing the same. The
|
||||
debuff half is the one worth caring about, because it is exploitable and needs no
|
||||
discovery to exploit — just fight everything with `!attack`.
|
||||
|
||||
**Not gaps** — checked and explained, so nobody re-files them:
|
||||
|
||||
- `PetAttackProc` and `SpiritWeaponProc` *are* reimplemented in the turn engine
|
||||
(`petStrike`, `spiritWeaponStrike`), though they fire after a player action
|
||||
rather than at round end.
|
||||
- `EnvironmentProc` is absent because `turnCombatPhase` is a single flat "Duel"
|
||||
phase with the proc at 0 — deliberate, not dropped.
|
||||
- `HealItem`'s auto-heal never fires, but the turn engine seeds and snapshots
|
||||
`HealChargesLeft` and gives the player `!consume` instead. Player-driven by
|
||||
design, not a leak. (Worth confirming the charges aren't double-counted across
|
||||
the two surfaces.)
|
||||
|
||||
**The achievement claim held when written; the fix moved it.** At the time,
|
||||
`combat_sniper_kill` and `combat_misty_clutch` were both unreachable from a
|
||||
manual kill and `seatCombatResult` hardcoded both flags false — so "six
|
||||
achievements is really four" was correct. Wiring the heal changed that:
|
||||
`seatCombatResult` now reads `MistyHealed` back off the `misty_heal` event on the
|
||||
log (the same way `combat_pet_save` has always been detected), so
|
||||
**`combat_misty_clutch` is reachable turn-based now** — it is five, not four.
|
||||
`combat_sniper_kill` stays unreachable. The other four were always reachable:
|
||||
`combat_near_death` (computed by `seatCombatResult`), `combat_pet_save` (the turn
|
||||
engine does call `resolveEnemyAttack`, which emits `pet_whiff`),
|
||||
`combat_death_save` (`trySave` in `stepRoundEnd`), and `combat_consumable_used`
|
||||
(`combat_cmd.go:773`).
|
||||
|
||||
(Keep this internal. Per project convention these procs are hidden discovery
|
||||
mechanics and must not surface in help text, player docs, or DMs.)
|
||||
|
||||
**Fix (2026-07-10).** Both Misty procs were hoisted out of `endOfRoundForSeat`
|
||||
into shared helpers — `mistyCrowdRevenge` and `mistyHeal` — and a `seatEndOfRound`
|
||||
hook that runs the pair in the order the auto-resolve engine does (crowd first,
|
||||
then, if the seat survived, the heal). `endOfRoundForSeat` now calls the helpers;
|
||||
the turn engine's `stepRoundEnd` calls `seatEndOfRound` per seat, after the
|
||||
poison tick so a heal can answer the round's damage. Both helpers short-circuit
|
||||
before touching the RNG when their proc is unarmed, so a character with no Misty
|
||||
history draws exactly the dice it drew before — the sim corpus and
|
||||
`combat_characterization.golden` do not move (full suite confirms).
|
||||
|
||||
`renderAllySeatEvent` gained a `crowd_revenge` case: an ally sees the damage land
|
||||
(silence would read as HP vanishing) but not Misty's name, matching the
|
||||
neighbouring `misty_heal`'s neutral "recovers N" — the grudge stays the owner's
|
||||
own discovery.
|
||||
|
||||
New tests: `combat_misty_turn_test.go` (6 cases) — the crowd landing on the
|
||||
manual surface (the exploit), a lethal crowd ending a solo fight, the heal firing
|
||||
and capping, no heal for a seat the crowd just dropped, the no-RNG-when-unarmed
|
||||
property that protects the golden file, and an end-to-end wiring test through
|
||||
`advanceCombatSession` that reports `PlayerHP=40` (the shipped exploit) if the
|
||||
`stepRoundEnd` call is removed.
|
||||
|
||||
### O. `trySimAutoArm` used to re-arm every round — sim baselines will move (NEW)
|
||||
|
||||
Before item A's fix, `trySimAutoArm` lived inside `buildZoneCombatants`. On the
|
||||
turn-based path that meant: every rebuild re-armed the class-default ability,
|
||||
**spent another point of the resource**, and re-applied it. A simulated Fighter
|
||||
got a fresh Second Wind heal every single round until stamina ran dry; a Cleric,
|
||||
a Healing Word.
|
||||
|
||||
`expedition-sim`'s `autoDriveCombat` drives `handleAttackCmd`, i.e. the
|
||||
turn-based path — so this inflated every turn-based sim result. It now arms once
|
||||
per fight, which is what `simAutoArmEnabled`'s own doc comment says it models
|
||||
("a competent real player who would `!arm` Second Wind before each fight").
|
||||
|
||||
**Any expedition-sim corpus that involved turn-based combat is stale.** The
|
||||
pending final L10/L12 re-baseline should be run after this change, not before.
|
||||
The pure auto-resolve corpus (`SimulateCombat` / `simulateParty`) is unaffected —
|
||||
that path always armed exactly once.
|
||||
|
||||
### ~~C. Members and leaders disagree about an expedition during `extracting`~~ — MIS-STATED; the real bug was worse. Fixed 2026-07-10.
|
||||
|
||||
**The premise was wrong.** `getActiveExpedition` — the *leader's* lookup — also
|
||||
filters `status = 'active'`. During `extracting` the leader gets "no active
|
||||
expedition" from `!expedition`, `!map`, `!camp` &c. exactly as the member does.
|
||||
They already agree, and honestly so: nobody is standing in the dungeon. Widening
|
||||
`expeditionForMember` would have made the member the only player who can see a
|
||||
paused expedition. Not done, and it should not be.
|
||||
|
||||
Reading for it surfaced the real defect underneath.
|
||||
|
||||
**The seven-day resume window was never enforced.** `releaseParty` fires only on
|
||||
a terminal status, and `dnd_expedition.go:428` justifies skipping it for
|
||||
`extracting` by asserting "the roster is cleared when the resume window lapses
|
||||
and the row flips to `failed`". Nothing did that. The only `extracting → failed`
|
||||
transition in the tree was inside `handleResumeCmd` — a lazy expiry that runs
|
||||
only when **the leader personally types `!resume`**.
|
||||
|
||||
So a leader who quit, forgot, or simply started a different expedition left the
|
||||
row `extracting` **forever**. `releaseParty` never ran, every member stayed
|
||||
seated, and `assertNotAdventuring` kept refusing them a run of their own. Fix #2
|
||||
gave them `!expedition leave` as an escape, which is why this was a permanent
|
||||
nag rather than a permanent soft-lock — but a player should not have to find it.
|
||||
|
||||
Three holes, all closed:
|
||||
|
||||
1. **No sweeper.** `sweepLapsedExtractions` (`dnd_expedition_extract.go`) reaps
|
||||
every `extracting` row past `completed_at + 7d` through `completeExpedition`,
|
||||
which releases the roster, and DMs the audience. Hourly ticker
|
||||
(`expeditionExtractionSweepTicker`), plus a one-shot at `Start()` — a lapse
|
||||
that happened while the bot was down is blocking those members *now*. The
|
||||
audience is read before the close-out, since `completeExpedition` disbands the
|
||||
roster `expeditionAudience` reads. `handleResumeCmd`'s lazy expiry stays, now
|
||||
sharing the `extractionLapsed` predicate (which treats a NULL `completed_at`
|
||||
as lapsed — it is the only clock the window has).
|
||||
|
||||
2. **The leader could orphan their own party.** `!expedition start` checked only
|
||||
`getActiveExpedition`, so a leader could start fresh on top of an extracted
|
||||
row. `!resume` resolves the *newest* `extracting` row, so the old one became
|
||||
unreachable — un-resumable, un-abandonable, roster still held. It now refuses,
|
||||
but only when that row has a roster (`partySize > 1`); a solo extraction
|
||||
strands nobody, so walking away from one stays a normal thing to do. A lapsed
|
||||
row is reaped in place and the start proceeds.
|
||||
|
||||
3. **The leader had no way out but to pay.** `expeditionCmdAbandon` resolved
|
||||
through `activeExpeditionFor` and so could not see the extracted row it owns —
|
||||
the leader had to buy a `!resume` just to abandon. Both it and
|
||||
`abandonExpedition` now span `extracting` via `ownedLiveExpedition`, which
|
||||
sorts active rows first so `expeditionCmdStart`'s run-spawn rollback still
|
||||
tears down the row it just created rather than an older extracted one. This
|
||||
also fixes `dnd_setup.go:394,449` for free: a leader who rerolled their
|
||||
character used to strand their whole party.
|
||||
|
||||
Abandoning now DMs the members too, and says the true thing when the party is
|
||||
standing in town rather than in the dungeon (supplies already spent, loot
|
||||
already banked — not "supplies are forfeit").
|
||||
|
||||
New tests: `dnd_expedition_extract_sweep_test.go` (6 cases) — the sweep releasing
|
||||
a stranded roster, leaving a live extraction alone, the NULL-`completed_at`
|
||||
predicate, abandon reaching an extracted row and freeing the party, abandon
|
||||
preferring the active row, and the no-live-row error.
|
||||
|
||||
Not covered: the two `expeditionCmdStart` / `expeditionCmdAbandon` refusals,
|
||||
which sit above the DM boundary. Still the `dmSink` gap.
|
||||
|
||||
### ~~D. Two turn-based win close-outs must be kept in sync by hand~~ — effect-drift closed 2026-07-10.
|
||||
|
||||
`finishPartyWin` (`combat_party_finish.go`) was a hand-copied sibling of the
|
||||
`CombatStatusWon` block in `finishCombatSession` (`combat_cmd.go`): mood
|
||||
scan, kill record, threat, boss threat, XP + near-death, loot, TwinBee line,
|
||||
continue hint. `finishPartyCombatSession` routes solo back to
|
||||
`finishCombatSession`, so the two must stay equivalent forever, with nothing
|
||||
forcing it. Item A was the first drift.
|
||||
|
||||
Item A's fix already pulled the *bookkeeping* out of both into
|
||||
`postCombatBookkeepingForSeat`. This pass hoists the remaining duplicated
|
||||
**effects** into three shared helpers in `combat_party_finish.go`, and routes
|
||||
both close-outs through them:
|
||||
|
||||
- `applyOwnerWinEffects(owner, enemyID, elite) bool` — the zone-kill record,
|
||||
room threat, and boss-defeat threat drop; returns `bossOnExpedition`. Fires
|
||||
once, owner-scoped (a member owns neither row).
|
||||
- `grantSeatWinXP(uid, hp, hpMax, monster, tier)` — near-death calc + XP grant.
|
||||
Per seat (solo calls it once with seat 0).
|
||||
- `endRunOnLoss(owner, runID, death)` — mood event (death only) + `abandonZoneRun`
|
||||
+ `forceExtractExpeditionForRunLoss`, shared by both the Lost and Fled paths.
|
||||
|
||||
What deliberately stays divergent, so this was **not** a full `closeOutSeat`
|
||||
merge: the player-facing text genuinely differs (solo "You finished at N/M HP"
|
||||
vs party "The party stands"; the party's 4-way boss/hint split vs the solo
|
||||
2-way), and the death-on-win rule is roster-size-dependent by design — solo
|
||||
`finishCombatSession` never marks a winner dead, `finishPartyWin` marks a seat
|
||||
that won from 0 HP dead. That is item E and must not be unified. So the effect
|
||||
lists are now a single copy each; the text is not.
|
||||
|
||||
Full plugin suite green; no behavior change (helpers are extract-and-call).
|
||||
|
||||
Note this is the *only* place the party work chose a parallel sibling. Elsewhere
|
||||
the branch is a genuine N-body generalization, not a bolt-on: `SimulateCombat` →
|
||||
`simulatePartyWithRNG`, `runCombatRound` → `runPartyCombatRound`, `runZoneCombat`
|
||||
→ `runZoneCombatRoster`, `RenderTurnRound` → `RenderPartyTurnRound`.
|
||||
|
||||
### E. Death-on-win depends on roster size — intentional, leave it
|
||||
|
||||
`closeOutZoneWin` marks a downed seat dead on a win only when `len(seated) > 1`.
|
||||
A solo player who wins at 0 HP (retaliate aura kills the swinger on the killing
|
||||
blow) survives; a party member in the same spot dies.
|
||||
|
||||
This is **deliberate and documented in place** (`zone_combat_party.go:183-188`):
|
||||
unifying it would move the sim's outcome labels and force a re-baseline of
|
||||
`testdata/combat_characterization.golden`. Not a defect. Listed so the next
|
||||
person to find it doesn't "fix" it by accident.
|
||||
|
||||
### F. `grantAutorunGrace` is a stopgap (already tracked as R5)
|
||||
|
||||
`zone_revisit.go:172` stamps `last_autorun_at` to buy one `autoRunCooldown`
|
||||
window after a `!revisit`, because the autopilot ticker has no concept of
|
||||
position-vs-frontier and will otherwise march the party back out of the room they
|
||||
just backtracked to. The code says so itself and defers the real fix to R5. It is
|
||||
coupled to the exact value of `autoRunCooldown` and to the ticker cadence.
|
||||
|
||||
### G. Party combat multiplies the known per-turn DB chatter
|
||||
|
||||
Already tracked as `project_combat_session_cache_deferred`, now worse:
|
||||
|
||||
- `partyCombatantsForSession` (`combat_session_build.go:137`) rebuilds every seat
|
||||
from scratch on every `!attack`/`!cast`/`!consume` (~6–8 SELECTs per seat).
|
||||
A 3-member, 5-round fight ≈ 270–360 SELECTs.
|
||||
- `rebuildRoster` (`combat_cmd.go:620`) then rebuilds *all* seats again after a
|
||||
mid-fight buff, when only the casting seat changed.
|
||||
- `runZoneCombatRoster` (`zone_combat_party.go:73`) loads char + equipment, then
|
||||
`buildZoneCombatants` loads both again and discards the first pair — once per
|
||||
seat per room, on the path that fights every room.
|
||||
- `nudgeStalledPartyTurns` (`combat_party_turn.go:281`) does two session reads
|
||||
plus a full N-seat rebuild per stalled fight, serially, on the 1-minute ticker,
|
||||
while holding the fight lock.
|
||||
- The enemy stat block is rebuilt once per seat and kept only for seat 0 (the
|
||||
code comments admit it). CPU-only, but pure waste.
|
||||
|
||||
Cheapest real fix: memoize the built roster for the fight's lifetime. Mid-fight
|
||||
buffs are already tracked as `ActorStatuses` deltas and re-applied by
|
||||
`applySessionBuffs`, so stats are reproducible without re-reading the DB.
|
||||
|
||||
### ~~H. Guard probes re-query per message~~ — MEASURED, DECLINED 2026-07-10.
|
||||
|
||||
`isPartyMember` → `activeExpeditionFor` is up to 2 queries; `activeZoneRunFor`
|
||||
adds more. A solo player with nothing in flight costs 3 queries to conclude "not
|
||||
in a party". The original write-up said this was "repeated by each guard a
|
||||
handler consults" and proposed resolving seat/expedition/run once per message
|
||||
into `MessageContext` and threading it.
|
||||
|
||||
**The premise was overstated, and the fix is disproportionate. Not done.**
|
||||
|
||||
- **No per-message guard cascade exists.** `OnMessage` dispatch is strictly
|
||||
one-handler-per-command (`if IsCommand(...) return handle(...)`). Nothing runs
|
||||
these resolvers for *every* message; the redundancy is intra-handler only.
|
||||
- **The one hot inbound path is already optimized.** `zoneCmdAdvance` /
|
||||
`expeditionCmdRun` call `isPartyMember` directly *by design* — the code comment
|
||||
spells out that this avoids re-resolving the run (which `advanceOnce` is about
|
||||
to resolve) and double-firing the §4.3 idle reap. The authors already closed the
|
||||
hot case.
|
||||
- **The remaining 2–3× callers are cold.** A per-function scan found ≥2 resolver
|
||||
calls only in `zoneCmdEnter`, `zoneCmdAbandon`, `expeditionCmdStart`, the status
|
||||
impls (all one-shot commands), and `runAutopilotWalk` (5×, but it is the
|
||||
*background* ticker path, not an inbound message). In those the calls are
|
||||
typically a guard-check on one branch then a resolve on another — not the same
|
||||
read twice.
|
||||
- **The proposed fix has a real regression surface.** `MessageContext` is a
|
||||
shared cross-plugin value type (movies, vibe, miniflux, …); the resolvers are
|
||||
free functions used at 60+ sites. Threading a resolved snapshot means changing
|
||||
the shared type and every call site, and carrying a memo that goes stale the
|
||||
moment a handler writes the row mid-flight — the exact staleness-bug class items
|
||||
A/M/N just fixed. All to save a few cheap indexed local SQLite reads on a
|
||||
low-volume bot.
|
||||
|
||||
Cost/benefit is upside-down: high churn + a staleness regression surface for a
|
||||
negligible perf gain on paths that are mostly already optimal. Left as-is
|
||||
deliberately. Revisit only if a profiler ever shows these reads on a hot path.
|
||||
|
||||
### ~~I. `parseTemperIndex` is the third copy of the same menu parser~~ — Fixed 2026-07-10.
|
||||
|
||||
`adventure_temper.go` duplicated the "read leading digits, 1-indexed →
|
||||
0-indexed, bounds-check" reply parser already inlined in `resolveMagicEquipReply`
|
||||
(`magic_items_gameplay.go`) and `handleMasterworkEquipReply`
|
||||
(`adventure_masterwork.go`). Promoted the temper copy to `parseMenuIndex(reply, n)`
|
||||
and routed the other two through it.
|
||||
|
||||
Behaviour-preserving on all three: the old `parsed` flag was redundant with the
|
||||
`idx < 0` guard (no leading digit leaves `idx=0`, then `idx--` → -1, rejected
|
||||
either way), so no input changes verdict. The **retry disagreement the doc
|
||||
flagged was deliberately left as-is** — this was a pure dedup, so magic-items and
|
||||
temper still re-store the pending interaction on a bad parse and masterwork still
|
||||
doesn't. Unifying that is a behaviour change and belongs in its own pass if
|
||||
wanted. Full plugin suite green; the existing `parseMenuIndex` table test moved
|
||||
with the rename.
|
||||
|
||||
### J. `!sell` fires the event anchor even when nothing sold (PLAUSIBLE)
|
||||
|
||||
`adventure.go:719` calls `maybeFireAnchoredEvent` unconditionally after
|
||||
`handleSellCmd`, so `!sell nonexistentitem` can burn the player's one daily event
|
||||
slot. Gating it needs `advSellItem`/`advSellAll` to return a `sold bool`.
|
||||
|
||||
**Probably leave it.** The anchor's stated design is presence-based — "a moment
|
||||
the player is present and reading" — and a player who typed `!sell` and read the
|
||||
reply *is* present. That matches the project's own rule that presence means any
|
||||
chat, not a successful command. Flagged only so the choice is explicit.
|
||||
|
||||
### K. Confirm the A6 anchor set covers the player population — CONFIRMED REAL GAP 2026-07-10.
|
||||
|
||||
N1/A6 replaced the old "any daily-active player, any activity" mid-day event roll
|
||||
with three presence anchors: the expedition Night digest, `!sell`, and arena
|
||||
cashout. `tryTriggerEvent`'s `HasActedToday` gate was dropped with it.
|
||||
|
||||
**The code confirms the gap is real, not hypothetical.** The three, and only
|
||||
three, `maybeFireAnchoredEvent` call sites are:
|
||||
|
||||
1. `expedition_autorun.go:312` — the end-of-day digest, and it is gated on
|
||||
`campDecision.Night`. A Night camp only happens on a **multi-day** expedition;
|
||||
a single-day dungeon/`!zone` run never reaches one.
|
||||
2. `adventure.go:734` — after a `!sell` at Thom's.
|
||||
3. `adventure_arena.go:633` — after an arena cashout.
|
||||
|
||||
So the excluded population is concrete: a player whose daily loop is
|
||||
mining / foraging / fishing / babysit / single-day dungeon, who never sells at
|
||||
Thom's, never enters the arena, and never runs a multi-day expedition, receives
|
||||
**zero** mid-day events — the roll never even happens for them. That is exactly
|
||||
the core daily-grind loop, so this is plausibly a large fraction of players, not
|
||||
an edge case.
|
||||
|
||||
**Fourth anchor drafted 2026-07-10 (uncommitted, pending owner review).** The
|
||||
on-theme, farm-resistant home is a foreground **single-day `!zone` clear** — the
|
||||
climax DM the dungeon/harvest-via-walk player is demonstrably reading, and one
|
||||
they cannot spam (a clear costs a full walk). Implemented as:
|
||||
|
||||
- `advEventChanceZoneClear = 0.08` in `adventure_events.go` (matches the digest;
|
||||
it is the day's climax moment, and it is the single tuning knob).
|
||||
- `advanceResult.zoneCleared` set true only in the *full*-clear branch of
|
||||
`advanceOnceWithOpts` (not a mid-zone region clear, which continues the run and
|
||||
would fire per region).
|
||||
- Rolled in `zoneCmdAdvance` after the clear DM streams, via the unchanged
|
||||
`maybeFireAnchoredEvent`. That is the foreground path only — autopilot
|
||||
(`runAutopilotWalk`) and party members (refused earlier by `isPartyMember`)
|
||||
never reach it. The per-day slot guard still caps everyone at one event/day.
|
||||
|
||||
Rates (see `TestZoneClearAnchorRate`): a zone-clear-only grind player at ~2
|
||||
clears/day lands ~1.08 events/week — the same band as the fully-engaged
|
||||
digest+sell+arena player (~1.19/week, unchanged). The rare all-four-in-one-day
|
||||
player peaks at a ~1.65/week *probability* ceiling but still gets at most one
|
||||
actual event/day.
|
||||
|
||||
**Two knobs remain the owner's call**, hence "pending review": the 0.08 chance,
|
||||
and whether a single-day zone clear is even the right anchor for the grind loop
|
||||
vs. `!resources` (rejected here as farmable — repeated `!resources` would just
|
||||
re-roll the same daily slot) or leaving the grind loop event-free by design.
|
||||
Full plugin suite green; no existing behaviour moved (the three prior anchors and
|
||||
`TestAnchoredEventWeeklyRate` are untouched).
|
||||
|
||||
### ~~L. Dead helper: `openParty`~~ — Verified and removed 2026-07-10.
|
||||
|
||||
Confirmed dead: a repo-wide grep found `openParty` only in `expedition_party.go`
|
||||
(the def) and `expedition_party_test.go`. The invite path goes `joinParty` →
|
||||
`seatLeader`, which runs the same `INSERT ... role 'leader' ON CONFLICT DO
|
||||
NOTHING` but reads the owner off the expedition row instead of trusting a
|
||||
passed-in id. Deleted `openParty`; the eleven test call sites now use a
|
||||
`seatLeaderFixture(t, expID)` helper that wraps `seatLeader` in a transaction
|
||||
(the owner comes from the `seedExpedition` row, which always matched the id the
|
||||
old calls passed). `seatLeader`'s doc comment absorbed the idempotency note that
|
||||
justified `openParty`. Full plugin suite green.
|
||||
|
||||
---
|
||||
|
||||
## Test gap worth closing — ~~the DM seam~~ CLOSED 2026-07-10
|
||||
|
||||
`SendDM` went straight through `Base` to a live client, with no fake/sink seam,
|
||||
so the handler-level paths behind fixes #1, #2 and #5 could not be unit-tested.
|
||||
That is why the party close-out bug survived a suite this thorough: every party
|
||||
test is unit-level and stops below the DM boundary.
|
||||
|
||||
**Fixed 2026-07-10.** Added a `MessageSink` interface and a `Base.Sink` field
|
||||
(`plugin.go`). When non-nil it captures every outbound message — both the DM
|
||||
route (`SendDM`/`SendDMID`) and the room route (`SendMessage`/`SendMessageID`,
|
||||
which is what the arena announcement actually uses) — and the live client is
|
||||
never touched. Nil in production, so behaviour is unchanged; the whole
|
||||
`internal/...` suite is green with the field added. One seam, no call-site churn:
|
||||
the 765 `SendDM`/`replyDM`/`SendMessage` sites are all downstream of these four
|
||||
methods.
|
||||
|
||||
New tests (`message_sink_test.go`): a `captureSink` test double + `installSink`
|
||||
helper; `TestMessageSink_CapturesDMAndRoom` proving both routes divert with the
|
||||
right target/text and that the `*ID` variants report back an id; and
|
||||
`TestExpeditionCmdLeave_DMsBothEnds`, which drives the real fix-#2 handler end to
|
||||
end and asserts both DMs land (member "turned back", leader notified) — a path
|
||||
that was literally a silent no-op in a unit test before. `beginCombatTurn`'s
|
||||
terminal path and the arena rollover announcement are now reachable the same way
|
||||
(seam proven for both the DM and room routes); dedicated end-to-end tests for
|
||||
those two remain easy follow-ups if wanted.
|
||||
|
||||
Added this pass: `TestPartyCasualtyLine_JoinsNamesLikeTheRestOfTheBot`,
|
||||
and `TestPartySurvivors_SplitsOnHPNotOnOutcome` was rewritten as
|
||||
`TestAnySurvivor_ReadsHPNotOutcome` when its helper was deleted.
|
||||
|
||||
Added in the second pass (`combat_armed_ability_test.go`): the close-out
|
||||
bookkeeping is now reachable below the DM boundary via
|
||||
`postCombatBookkeepingForSeat(sess, seat)`, which takes a plain `*CombatSession`
|
||||
and needs no client — so item A's behaviour is directly unit-tested even though
|
||||
`finishCombatSession` itself still isn't. (The `dmSink` seam that would have
|
||||
covered `finishCombatSession` itself now exists — see above.)
|
||||
|
||||
Not covered by tests: item B's locking. `withExpeditionSupplies` is exercised
|
||||
incidentally by the existing expedition suite, but nothing asserts the mutual
|
||||
exclusion. A `t.Parallel` accept-vs-burn race test under `-race` would.
|
||||
@@ -1,519 +0,0 @@
|
||||
# Combat engine — paying off the N-body debt
|
||||
|
||||
## Session 2026-07-11 (c) — §6 was never a balance problem
|
||||
|
||||
**IN FLIGHT: the verification sweep is still running on millenia** (`/tmp/s6_after.jsonl`,
|
||||
10 classes × L10,L12 × 10 zones × n=25). Nothing below the diagnosis is measured yet.
|
||||
|
||||
### Committed
|
||||
|
||||
- `c9128fb` — §1's other half. Out-of-combat `!cast <heal> @friend`, scoped to the
|
||||
people on your expedition. `--target` had been advertised by `!help` and swallowed
|
||||
by the parser since SP2. Ally row is mutated with one guarded UPDATE in a
|
||||
transaction (gifting's precedent), not a read-modify-write under a second lock:
|
||||
two clerics healing each other would take their `advUserLock`s in opposite orders
|
||||
and deadlock. Refunds the slot on every path that heals nobody.
|
||||
- §6 auto-cast (`zone_combat_autocast.go`) — see below. Tested, unmeasured.
|
||||
|
||||
### The finding: cleric was not weak, it was not being played
|
||||
|
||||
Baseline re-run on HEAD (`sim_results/s6_baseline_l10l12.jsonl`, 5000 rows) confirmed
|
||||
cleric still dead last: **46.4% vs fighter 81.4%**. §1's self-heal moved it ~0, as
|
||||
predicted. But the *shape* of the failures is the whole story:
|
||||
|
||||
| class | cleared | **fled** | tpk |
|
||||
|---|---|---|---|
|
||||
| fighter | 407 | **0** | 93 |
|
||||
| ranger | 395 | **0** | 105 |
|
||||
| cleric | 232 | **167** | 101 |
|
||||
|
||||
**Cleric dies LESS than mage, sorcerer or warlock.** Its entire 35pp deficit is
|
||||
*fleeing* — 167 of 500 runs end with the player alive and the expedition over.
|
||||
Instrumenting the three `stopEnded` sites: **every one of those runs died at
|
||||
`stopEnded via ROOM`** — an ordinary room fight. Not a patrol, not an elite, not the
|
||||
boss.
|
||||
|
||||
**Root.** An ordinary room is `runZoneCombatRoster` → `SimulateCombat` on an 8-round
|
||||
clock: one breath, **no turn engine, no action picker**. The only spell that could
|
||||
ever land there was a `PendingCast` the player queued BY HAND with `!cast` before
|
||||
walking in. On autopilot nobody queues one — so for every room of every expedition, a
|
||||
caster fought with a weapon and nothing else. A cleric has no Extra Attack, a mace,
|
||||
and its whole kit unusable. Measured on the room path at L10 (loss% by entry HP):
|
||||
|
||||
| entry HP | fighter | druid | bard | mage | sorcerer | **cleric** |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 100% | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% |
|
||||
| 70% | 0.0% | 0.0% | 0.0% | 0.0% | 1.0% | **1.5%** |
|
||||
| 35% | 0.0% | 0.0% | 0.5% | 0.5% | 10.0% | **8.0%** |
|
||||
|
||||
Small per room — and a run is ~16 rooms, and **one loss ends the expedition**
|
||||
("the fight drags on, X outlasts you, you retreat"). It compounds into 33% of runs.
|
||||
|
||||
**The tell.** `dnd_class_balance.go:431` — the harness the class corpus was tuned on
|
||||
— *always* hands a caster their best damage spell (`pickBestDamageSpell` +
|
||||
`applyHarnessSpellCast`) before it simulates. So the numbers that say "cleric is a
|
||||
weak class" modelled a cleric who casts; the live room modelled one who does not.
|
||||
The corpus and the game disagreed about what a cleric is.
|
||||
|
||||
This is the same defect as everything else in this plan: **the action model is
|
||||
narrower than the kit.** §1 (the picker never healed), §3 (the companion never
|
||||
acted), Pete (`LoadDnDCharacter` → nil → `"attack"`), and now every caster in every
|
||||
room. It is not a tuning dial and it must not be fixed with one.
|
||||
|
||||
### The change (unmeasured)
|
||||
|
||||
`autoCastForAutoResolve` — no queued spell + a real slot in hand → cast the best
|
||||
damage spell, spend the slot, fold it in. Additive pre-damage, exactly as a
|
||||
hand-queued `PendingCast` has always been, so it stays comparable to the corpus
|
||||
rather than being a fresh mechanic.
|
||||
|
||||
- **Slot-aware, not free.** `pickBestDamageSpell` reads `slotsForClassLevel` — the
|
||||
*theoretical* class table. Reusing it here would be an infinite spell: the same
|
||||
"no row to persist onto, so it arrives fresh" bug that gave the companion an
|
||||
unlimited body ([[project_companion_free_lunches]]). The new picker reads the
|
||||
seat's actual remaining slots and `consumeSpellSlot`s.
|
||||
- **Never upcasts.** The big slots are what the elite and the boss are for, and the
|
||||
turn engine spends them there. A picker that nukes a goblin with a 5th-level slot
|
||||
leaves the caster swinging a stick at the thing that matters.
|
||||
- Cleric owns no damage spell at slot 2 or 4, so those stay unspendable in rooms —
|
||||
correct, and pinned by a test.
|
||||
- Both goldens byte-identical (they pin the engine; this changes its *inputs* at the
|
||||
zone layer). Martials provably untouched.
|
||||
|
||||
### When the sweep lands
|
||||
|
||||
Read cleric's **fled** count, not just its clear rate — fled going to ~0 is the
|
||||
thing this predicts. Expect all 6 casters to move; that is the deliberate
|
||||
re-baseline. **Watch for overshoot on bard/druid** (already 67–72%): they get the
|
||||
same buff and were not the problem. If they overshoot, the lever is this picker, not
|
||||
monster scaling ([[project_difficulty_target]]).
|
||||
|
||||
|
||||
## Session 2026-07-11 (b) — the companion can cast, and three free lunches fell out
|
||||
|
||||
**Committed: `01c2cb2` (§1 — the seat-scoped spellbook).** Everything below it is
|
||||
working-tree.
|
||||
|
||||
The revisit-Pete step ("Pete cannot cast") turned out to be four separate defects
|
||||
stacked on each other. The unit tests were green for all four; only the sweep with
|
||||
a **control arm** ever told the truth. Read [[feedback_unit_tests_miss_engine_bugs]]
|
||||
and then read it again.
|
||||
|
||||
### The one that was asked for
|
||||
|
||||
Every spell lookup is keyed on a user id and answered by a `dnd_*` table. Pete has
|
||||
rows in none of them, by design. So `pickAutoCombatActionForSeat`'s first line —
|
||||
`LoadDnDCharacter(uid)` → nil → `return "attack"` — meant **a hired Cleric swung a
|
||||
mace, every turn, forever**. Role-fill hands a lone martial a Cleric, so that was
|
||||
the *common* case. Fixed with a seat-scoped spellbook (`combat_seat_spells.go`):
|
||||
humans delegate to the DB verbatim (both goldens byte-identical), the companion is
|
||||
answered from his synthetic sheet.
|
||||
|
||||
### The three that were not
|
||||
|
||||
Each of these was found by measuring, and each is the same mistake: *"he has no row
|
||||
to persist onto, so he arrives fresh."*
|
||||
|
||||
1. **His spell slots refilled every fight.** I parked the ledger on his combat
|
||||
*seat* — and a seat is per-session. A human rations one pool across a 30-room
|
||||
run and gets it back at camp; rationing it **is** the caster's game. Moved to
|
||||
`expedition_party.companion_slots_used`, refreshed at camp.
|
||||
*(Worth ~0pp on its own — an expedition only holds ~2 real fights, so the pool
|
||||
never binds. Correct, but not the lever. I predicted this one would be the whole
|
||||
answer and I was wrong.)*
|
||||
2. **His body refilled every fight.** `combat_party_start.go` seated him at
|
||||
`player.Stats.MaxHP` and the close-out skipped him with the comment *"he arrives
|
||||
fresh next time"*. That is an infinite body: he soaked a share of every fight's
|
||||
incoming and then reset, while the humans beside him bled all the way to camp.
|
||||
**This was the carry.** Moved to `expedition_party.companion_hp`; healed at camp.
|
||||
3. **No autopiloted caster has ever healed itself.** `simPickAllyHeal` skipped
|
||||
`i == seat` and bailed on `!IsParty()`. Between them: a solo cleric carries
|
||||
`cure_wounds` for a whole run and dies with a full pool of level-1 slots. Now
|
||||
`simPickHeal`: heal whoever is worst off, which is sometimes you.
|
||||
**It is NOT the answer to §6, and I guessed that it would be.** It moved solo
|
||||
66.1% → 66.2%: the picker reaches for a healing *consumable* first and the sim
|
||||
stocks them, so a human almost never falls through to the spell. The corpus is
|
||||
therefore undisturbed and no re-baseline is owed. Pete carries no consumables,
|
||||
so it is his only heal — which is the reason to keep it.
|
||||
|
||||
### What the arms actually said
|
||||
|
||||
640 runs/arm, 10 classes × L10,L12 × 4 zones. "Like-for-like" = the leaders whose
|
||||
role-fill gives Pete a **Cleric**, compared against a human **Cleric** follower.
|
||||
|
||||
| arm | like-for-like clear | vs solo |
|
||||
|---|---|---|
|
||||
| solo | 68.2% | — |
|
||||
| + a human cleric follower (leader's level, geared) | 79.7% | +11.5pp |
|
||||
| + Pete, HEAD (free heal, cannot cast) | 77.1% | +8.9pp |
|
||||
| + Pete, casting + free heal | **94.8%** | +26.6pp — **carry** |
|
||||
| + Pete, casting + carries his wounds | **69.5%** | +1.3pp — **useless** |
|
||||
|
||||
**The reference arm is the whole point.** Measured against *solo*, even mace-only
|
||||
Pete looked like a carry (+8.9pp) — but parties are *designed* to be safer, so solo
|
||||
is the wrong yardstick. Measured against **a human follower**, the real finding
|
||||
appears: a gearless, level-penalized hireling was out-clearing a fully-geared human
|
||||
cleric *of the leader's own level* by 15pp. A hireling must never beat a player.
|
||||
His party fled 5 runs out of 640; the human party fled 56.
|
||||
|
||||
### §2(a) SHIPPED — and it landed in band
|
||||
|
||||
`055f07d`. Seats carry a **SeatWeight**; both levers (boss HP, enemy action economy)
|
||||
scale on the summed weight of the **living** seats instead of a head count. The
|
||||
weight is **level-based** — priced against the leader, times a hireling discount
|
||||
(`companionSeatWeight = 0.65`, the one tunable).
|
||||
|
||||
**Not** a power score: HP×damage would rank a cleric below a fighter and quietly
|
||||
make every mixed *human* party easier — a difficulty regression smuggled in under a
|
||||
bug fix.
|
||||
|
||||
The safety argument is one property: **a peer weighs exactly 1.0.** The curves
|
||||
interpolate between P8's tuned integer knots — (1, 1.0), (2, 2.4), 2n−1 from 3 up —
|
||||
so every integer input returns exactly what it always returned. Solo byte-identical,
|
||||
a party of same-level humans byte-identical, both goldens unmoved. Only an *unequal*
|
||||
roster lands between knots, which is the whole point. It also finishes §2(b): a
|
||||
downed seat now buys the enemy nothing (§2(b) fixed only the head-count half — a
|
||||
corpse still carried its full weight).
|
||||
|
||||
| | solo | + Pete | + human cleric peer |
|
||||
|---|---|---|---|
|
||||
| like-for-like clear | 69.0% | **76.8%** (+7.8pp) | 77.6% (+8.6pp) |
|
||||
|
||||
| band | solo | + Pete | lift |
|
||||
|---|---|---|---|
|
||||
| trailing (solo <40%) | 10.0% | 31.0% | **+21.0pp** |
|
||||
| middle | 58.9% | 76.8% | +17.9pp |
|
||||
| leading (solo ≥70%) | 93.5% | 99.2% | **+5.7pp** |
|
||||
|
||||
Help, never a carry — and he stays below a real human of the leader's level, which
|
||||
is the invariant a hireling must never break.
|
||||
|
||||
### How it got there (the record of being wrong)
|
||||
|
||||
With all three free lunches gone, the same grid says (like-for-like subset):
|
||||
|
||||
| | solo | + human cleric peer | + Pete |
|
||||
|---|---|---|---|
|
||||
| clear | 69.0% | 77.6% (+8.6pp) | **66.1% (−2.9pp)** |
|
||||
|
||||
**Hiring him is now worse than going alone** — and that is §2, unmasked. The free
|
||||
full-heal had been paying his enemy-scaling bill for him. A below-median seat costs
|
||||
the boss +15% HP and lifts the enemy's action economy from 1 to 2.4 attacks a round,
|
||||
and he does not give that much back.
|
||||
|
||||
I talked myself out of §2(a) mid-session, on the grounds that discounting a weak
|
||||
seat would enlarge the carry. That was right *about the buggy build* and wrong about
|
||||
the engine: with the infinite body removed, the plan's original diagnosis is exactly
|
||||
correct and the sweep now argues **for** §2(a).
|
||||
|
||||
I talked myself **out** of §2(a) mid-session, on the grounds that discounting a weak
|
||||
seat would enlarge the carry. That was right about the *buggy build* and wrong about
|
||||
the engine: the infinite body had been paying the seat's scaling bill for it. With
|
||||
the free lunches gone, the plan's original diagnosis was exactly correct. **The
|
||||
order mattered** — §2(a) done first, on top of the free full-heal, would have made
|
||||
things worse and looked like it was working.
|
||||
|
||||
## Still open
|
||||
|
||||
- A dropped companion returns at 1 HP (`companionSeatHP`'s floor) rather than being
|
||||
benched until camp. Deliberate: there is no companion-death rule and inventing one
|
||||
inside a bug fix would have been a second feature.
|
||||
- **§6 is still open, and self-heal was NOT it.** Solo cleric is 28–34% against
|
||||
fighter's 100% on this grid. Re-read it off the current corpus before tuning.
|
||||
- `companionSeatWeight = 0.65` is the single tunable and was not swept — it landed
|
||||
in band on the first value tried. If Pete ever needs to be weaker, raise it.
|
||||
- Out-of-combat `!cast --target` (`dnd_cast.go`) is still self-only.
|
||||
- **Deployable now.** Hiring him is a real, bounded help at every level.
|
||||
|
||||
|
||||
> The party engine (N3) widened the *roster* from 1 to N but never widened the
|
||||
> *action model*, the *scaling model*, or the *test net* to match. Everything
|
||||
> below is a symptom of that one gap. Working-tree doc; do not commit
|
||||
> (feedback_dont_commit_plan_mds).
|
||||
|
||||
## Status @ 2026-07-11 — §1–§5 SHIPPED (uncommitted, unmeasured)
|
||||
|
||||
All five sections are implemented and the suite is green. Nothing is committed.
|
||||
|
||||
| § | What | State |
|
||||
|---|------|-------|
|
||||
| §5 | Party golden + seat attribution | ✅ `party_characterization.golden` (1938 lines, 9 scenarios × 5 seeds) + `TestPartyCharacterization_OneSeatIsStillSolo`; sim trace gets `simTraceEvent` (seat always emitted). Wire format left alone — `TestP5Fields_StayOffSoloRows` was right and I was wrong. |
|
||||
| §3 | Engine-driven seats | ✅ `ActorStatuses.EngineDriven`, permanent, no command clears it. `beginCombatTurn` now REFUSES a command from an engine seat. `autoDriveCombat` calls `driveEngineSeat` instead of impersonating the seat. |
|
||||
| §2(b) | Living-seat action economy | ✅ `enemyActionsThisRound` counts `livingActors(st)`, not `len(st.actors)`. **Party golden moved deliberately** (survivor ends 59 HP, was 51). Solo golden byte-identical. |
|
||||
| §4 | One roster accessor | ✅ `expeditionParty()` / `partyHumans()` / `PartySeat{Kind}`. Cannot return an empty party for a solo run — that is the level-1-companion bug, pinned by `TestParty_SoloExpeditionStillHasAParty`. |
|
||||
| §1 | Ally-targeted actions | ✅ `turnActionEffect.AllyHeal/AllySeat`; `!cast cure wounds @alex` **and** `--target @alex` (the flag `!help` advertised and `parseCombatCast` silently swallowed since SP2). `simPickAllyHeal` so autopiloted/engine healers use it. Will not raise the dead. |
|
||||
|
||||
**Both goldens hold. Solo is byte-identical throughout — the balance corpus is
|
||||
intact.**
|
||||
|
||||
### Post-fix sweep (2026-07-11, n=750/arm on millenia)
|
||||
|
||||
| | clear |
|
||||
|---|---|
|
||||
| solo | 48.5% |
|
||||
| solo + hired Pete | **63.9%** (+15.3pp) |
|
||||
| solo *(the 2-human cells)* | 65.7% |
|
||||
| \+ a human cleric follower | **87.7%** (+22.0pp) |
|
||||
|
||||
Pete went from **−28pp (worse than nobody) to +15.3pp**, and the lift lands exactly
|
||||
where the plan asked for it:
|
||||
|
||||
| band | n | mean lift |
|
||||
|---|---|---|
|
||||
| trailing cells (solo <40%) | 15 | **+28.0pp** |
|
||||
| middle (40–70%) | 3 | +5.3pp |
|
||||
| leading cells (solo ≥70%) | 12 | **+2.0pp** |
|
||||
|
||||
That is "help, never a carry", measured rather than asserted: he rescues the
|
||||
players who were drowning and barely moves the ones who were already fine. §2(a)
|
||||
may now be unnecessary — the §2(b) fix appears to have been most of it.
|
||||
|
||||
**⚠️ READ THE NOISE FLOOR BEFORE TRUSTING ANY CELL.** The sim is not seeded per
|
||||
cell. The same binary, same cell (bard/L10/feywild, n=25), three times:
|
||||
**36% / 56% / 56%** — a 20pp spread from RNG alone.
|
||||
|
||||
- **Per-cell numbers at n=25 are noise.** Do not tune against them.
|
||||
- Only the **n=750 aggregates** carry signal. The solo arm's 51.9% → 48.5% drift
|
||||
across engine versions is inside that noise band (and the solo golden is
|
||||
byte-identical, so solo combat provably did not change).
|
||||
- Anything huge (the old fighter/L10/feywild 100% → 4%) is real; anything under
|
||||
~20pp on a single cell is not.
|
||||
- **Next sweep: raise `-runs` to 100+ per cell** if per-cell reads are needed.
|
||||
|
||||
### What is NOT done
|
||||
|
||||
- **Measurement.** The engine moved under every number in this doc. A full
|
||||
re-sweep (solo / solo+Pete / 2-human-with-a-cleric) is in flight; every figure
|
||||
quoted below predates §1–§5 and is now **stale**.
|
||||
- **§2(a)** — power-based enemy scaling. §2(b) only stopped corpses from buffing
|
||||
the boss. A *weak* living seat still costs a full seat's worth of enemy scaling,
|
||||
which is why hiring Pete could still hurt a strong leader.
|
||||
- **§6** — solo cleric bump. Blocked on the re-baseline, deliberately.
|
||||
- **Pete cannot cast.** `castActionForSeat` loads a sheet from the DB and he has
|
||||
none by design, so a hired "Cleric" *still* cannot heal. §1 fixed human clerics;
|
||||
the companion needs a synthetic spell pool (or to be restricted to martial
|
||||
chassis). **This is the revisit-Pete step.**
|
||||
- Out-of-combat `!cast --target` (`dnd_cast.go`) is still self-only. Combat is
|
||||
where it mattered; that one can follow.
|
||||
|
||||
## Why this plan exists
|
||||
|
||||
The Pete companion (`!expedition hire`) was built in one session and worked on
|
||||
every unit test while being, in the sweep, **worse than no companion at all**
|
||||
(solo 65% clear → solo+Pete 33%). Chasing that found four defects. Only one of
|
||||
them was Pete's. The rest were sitting in the engine, in prod, since N3:
|
||||
|
||||
| Symptom found via the companion | Who else it affects | Root |
|
||||
|---|---|---|
|
||||
| A hired Cleric can't heal anyone | **Every human cleric in every party** | §1 |
|
||||
| A weak seat makes the party *worse* | Any under-levelled friend; every downed seat | §2 |
|
||||
| The companion lost his turn after round 1 | Any engine-driven seat, incl. away-player autopilot | §3 |
|
||||
| The companion was silently level 1 | Anything reading "the party" of a solo run | §4 |
|
||||
| Nobody noticed any of it | — | §5 |
|
||||
|
||||
§5 is the one that matters most: **there is no party golden.** The
|
||||
characterization golden (`combat_characterization.golden`, 7468 lines) runs
|
||||
`simulateCombatWithRNG(player, enemy)` — one player, one enemy, every scenario.
|
||||
Party combat has *no* pinned behaviour, so N3 shipped a healer class that cannot
|
||||
heal and the corpus said nothing. Whatever else we do, that gets fixed.
|
||||
|
||||
**Sequencing:** §5 first (build the net), then §1–§4 (change the engine while the
|
||||
net is under us). Doing it the other way round means changing the most heavily
|
||||
tuned code in the repo with no way to see what moved.
|
||||
|
||||
---
|
||||
|
||||
## §1 — Actions cannot target another seat
|
||||
|
||||
**The bug.** Every heal in the engine is self-scoped. `MistyHealProc`/
|
||||
`MistyHealAmt` heal the actor. `HealItem`/`HealItemCharges` fire the actor's own
|
||||
sub-50% trigger. `grep` for an ally-targeted action returns nothing; the only
|
||||
seat-targeting that exists anywhere is `enemyTargetSeat` — which seat the
|
||||
*monster* swings at. A party cleric is a cleric in chassis and passives only:
|
||||
**they cannot put one hit point back on a friend.**
|
||||
|
||||
**Root.** `PlayerAction{Kind, Effect}` (`combat_turn_engine.go:51`) has no target
|
||||
field. It was written when "the player" was unambiguous, and N3 never revisited
|
||||
it — the roster got wider, the action stayed pointed at the same two things (me,
|
||||
and the enemy).
|
||||
|
||||
**Change.**
|
||||
- `PlayerAction` gains a target: `TargetSeat int` (−1 = the enemy, the default,
|
||||
which keeps every existing call site meaning exactly what it means today).
|
||||
- `turnActionEffect` grows an ally-heal payload; `runPartyCombatRound` applies HP
|
||||
deltas to `TargetSeat` rather than to the actor.
|
||||
- `!cast <spell> @user` parses a target; solo ignores it.
|
||||
- Cleric/Druid/Bard/Paladin get their heal spells actually wired to it.
|
||||
- `pickAutoCombatActionForSeat` gains a heal-the-lowest-living-seat rule, so
|
||||
autopiloted and companion healers behave like a competent player.
|
||||
|
||||
**This moves the golden.** It adds an action the picker can choose. Deliberate
|
||||
re-baseline, after §5.
|
||||
|
||||
**Risk.** Touching `runPartyCombatRound` is touching the most tuned code we own.
|
||||
Mitigated by §5's party golden plus the existing solo golden staying byte-identical
|
||||
(no solo fight has a second seat to target).
|
||||
|
||||
---
|
||||
|
||||
## §2 — Enemy scaling counts seats, not strength
|
||||
|
||||
**The bug.** P8 scales the enemy's action economy to the roster, and
|
||||
`partyEnemyHPScale` gives +15% HP for any roster ≥ 2. Both count **seats**. So a
|
||||
seat contributes its full cost to the enemy the moment it sits down, regardless
|
||||
of what it actually brings — and keeps contributing that cost **after it dies**.
|
||||
|
||||
Measured, in the same cell (fighter L10, feywild):
|
||||
|
||||
| | clear |
|
||||
|---|---|
|
||||
| solo | 100% |
|
||||
| \+ Pete as Cleric (a chassis he can't play) | 32% |
|
||||
| \+ Pete as Fighter (his best case) | 68% |
|
||||
| \+ a second *human* | 100% |
|
||||
|
||||
A deliberately below-median body is a **net negative at every level** — the boss
|
||||
gains more than the seat gives back. That is not a Pete property. It is true of
|
||||
any under-levelled friend you invite, and of every seat that goes down early
|
||||
(the corpse keeps inflating the boss for the survivors).
|
||||
|
||||
**Root.** The scaling model assumes every seat is a median player. Nothing
|
||||
enforces that, and two shipped features (parties, then companions) violate it.
|
||||
|
||||
**Change — pick one, and it is a design call, not a mechanical one:**
|
||||
- **(a) Scale on contributed power, not seat count.** Enemy HP/actions scale on
|
||||
the roster's summed effective level relative to the leader's. Correct, and it
|
||||
fixes the under-levelled-friend case too. Most work; needs its own sweep.
|
||||
- **(b) Scale on *living* seats, re-derived per round.** Cheap, and kills the
|
||||
dead-seat-still-inflates-the-boss bug on its own. Does not fix the
|
||||
weak-seat case.
|
||||
- **(c) Don't scale for engine-driven seats at all.** Cheapest; makes hiring
|
||||
strictly positive. Risks "carry", and leaves the human-party half untouched.
|
||||
|
||||
Recommendation: **(b) now** (it is a straight bug — a dead man should not be
|
||||
buffing the boss), then **(a)** as the real fix once §5 can see it.
|
||||
|
||||
---
|
||||
|
||||
## §3 — Engine-driven seats are a status flag, not a concept
|
||||
|
||||
**The bug.** A seat with nobody at the keyboard is modelled as
|
||||
`ActorStatuses.Autopilot = true`. But `beginCombatTurn` clears that flag on any
|
||||
command arriving from that seat — *"They typed, so they are here."* And
|
||||
`autoDriveCombat` drives a party by **dispatching each seat's turn as a fake chat
|
||||
command from that seat's Matrix ID**. So the autopilot driver's own move looked
|
||||
like the player coming back, cleared the latch, and the seat went inert for the
|
||||
rest of the fight.
|
||||
|
||||
That is what made the companion stand in fights doing nothing. The same shape is
|
||||
live for away-player autopilot today, and it is only luck that a human eventually
|
||||
types something and re-latches.
|
||||
|
||||
**Root.** There is no first-class notion of "this combatant is driven by the
|
||||
engine". Ownership of a turn is inferred from a Matrix message, which is a
|
||||
transport detail leaking into the combat engine.
|
||||
|
||||
**Change.**
|
||||
- A seat is `DrivenBy: human | away | engine`, persisted, not a bool that any
|
||||
command can clear. `engine` is permanent; `away` is what a keystroke clears.
|
||||
- `autoDriveCombat` stops impersonating seats. It calls the seat driver directly
|
||||
(`driveCompanionSeat` is the shape; generalize it to any non-human seat).
|
||||
- Command handlers refuse any non-human seat outright, rather than half-working.
|
||||
|
||||
**Bonus.** This is the prerequisite for the deferred Phase-16 "Pete runs his own
|
||||
expeditions", and for any future NPC ally.
|
||||
|
||||
---
|
||||
|
||||
## §4 — "the party" and "the roster" disagree
|
||||
|
||||
**The bug.** A solo expedition has **no `expedition_party` rows** (absence means
|
||||
solo; the roster materializes on the first invite). So any code that answers
|
||||
"who is in this party?" by reading the roster gets **nobody** for a solo player
|
||||
— and a plausible-looking fallback silently takes over. That is exactly how the
|
||||
companion got hired at **level 1** for every solo player: the one case the
|
||||
feature exists for.
|
||||
|
||||
**Root.** Two representations of the same fact ("who is on this expedition") that
|
||||
disagree in the solo case, with no single accessor. `partySize()` and
|
||||
`partyMembers()` and `expeditionAudience()` and `expeditionSeats()` and
|
||||
`fightRoster()` all answer slightly different questions and are easy to reach for
|
||||
by the wrong name — I reached for the wrong one twice while building the
|
||||
companion, once fixed by a test and once only by a 1500-run sweep.
|
||||
|
||||
**Change.** One accessor that always includes the owner —
|
||||
`expeditionParty(expeditionID) []Member` with `Member.Kind = leader|member|companion`
|
||||
— and every consumer derives its own view from it (mail excludes companions,
|
||||
seats include them, supply burn counts mouths). Delete the ad-hoc set.
|
||||
|
||||
---
|
||||
|
||||
## §5 — There is no party golden (do this first)
|
||||
|
||||
**The bug.** `TestCombatCharacterization` pins *solo* combat, exhaustively (57
|
||||
scenarios × seeds). Party combat has nothing. The entire N3 engine — initiative,
|
||||
seat order, enemy targeting, action economy, close-out — is unpinned. That is why
|
||||
a healer class that cannot heal shipped without a single test going red, and why
|
||||
the companion's collapse was invisible until a 1500-run sweep.
|
||||
|
||||
**Change.**
|
||||
- `TestPartyCharacterization` + `party_characterization.golden`: N-seat rosters
|
||||
(2 and 3), mixed classes, downed seats, an engine-driven seat, seeded and
|
||||
byte-pinned exactly like the solo one.
|
||||
- A **balance regression sweep** in CI-able form: the `expedition-sim` matrix at
|
||||
low n, asserting clear-rates stay inside a band. The sweep is what caught all
|
||||
of this; it should not have to be run by hand.
|
||||
- Fix event seat attribution: `CombatEvent.Seat` is `omitempty`, so seat 0 and
|
||||
"no seat" are indistinguishable in a trace — which cost real time during this
|
||||
investigation, sending me after a phantom "Pete never swings" bug that was
|
||||
partly a reporting artifact.
|
||||
|
||||
---
|
||||
|
||||
## §6 — Clerics are weak in solo (raised 2026-07-11)
|
||||
|
||||
Separate from the party gap, and real: the class corpus has cleric in the 46–56%
|
||||
band against fighter's ~82% ([[project_d8prereq_baseline]]).
|
||||
|
||||
**Do not tune this yet, and specifically do not tune it in isolation.** Two
|
||||
reasons:
|
||||
|
||||
1. §1 *is* the cleric fix in party play — an ally-targeted heal is the class's
|
||||
entire identity, and today they cannot use it on anybody. Whatever solo gap
|
||||
remains should be measured **after** that lands, or we buff them twice.
|
||||
2. `d8prereq_corpus` predates parties entirely and the party engine has moved
|
||||
twice today (§2b, and §1 next). It is a stale baseline. **Re-baseline first**,
|
||||
then read the cleric gap off the new corpus.
|
||||
|
||||
Then, if solo cleric is still short: lift the trailer, per the standing rule
|
||||
([[project_difficulty_target]]) — do not nerf the martial leaders and do not touch
|
||||
monster scaling.
|
||||
|
||||
## Ordering
|
||||
|
||||
1. **§5** — build the net. Party golden + seat attribution. No behaviour change.
|
||||
2. **§3** — engine-driven seats. Contained, no balance impact, unblocks the rest.
|
||||
3. **§2(b)** — living-seat scaling. Straight bug fix; party golden will move once,
|
||||
deliberately.
|
||||
4. **§4** — unify the roster accessor. Mechanical, guarded by §5.
|
||||
5. **§1** — ally-targeted actions + heals. The big one. Moves both goldens;
|
||||
re-baseline the class corpus after ([[project_d8prereq_baseline]] predates
|
||||
parties entirely and is due anyway).
|
||||
6. **§2(a)** — power-based scaling, then re-sweep the companion and re-decide his
|
||||
below-median premise. It may not need to survive: once the boss stops
|
||||
overcharging for him, "below median" may be exactly right.
|
||||
|
||||
## Meanwhile: the companion
|
||||
|
||||
He is **fixed and net-positive where he is meant to be** (+15.7pp in trailing
|
||||
cells, ~neutral overall), but he still regresses strong solo leaders (§2) and
|
||||
role-fill still hands martials a Cleric who cannot heal (§1). Two options until
|
||||
this plan lands:
|
||||
|
||||
- **Ship him martial-only** (he plays chassis he can actually use — measured
|
||||
32% → 68%), and let §1 restore the healer fill later. Honest, cheap, no engine
|
||||
change.
|
||||
- **Hold him** until §1/§2 land, and announce the rest of Adventure without him.
|
||||
|
||||
Do NOT bandaid by hand-tuning his stats until he "looks right" — his numbers are
|
||||
not the problem, and tuning them would bake the engine's bugs into his stat block.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,280 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,356 +0,0 @@
|
||||
# Mischief Makers — paid monster hits on live expeditions
|
||||
|
||||
Working-tree plan doc. Do not commit.
|
||||
|
||||
## The pitch (restated)
|
||||
|
||||
Parodia members spend euros to send a monster — grunt / mob / elite / boss — at a
|
||||
player who is out on an expedition. Orders come from Pete's Adventure web page
|
||||
(and from Matrix). The games room hears that a contract is out and gets a window
|
||||
to help the target… or "help" them. Surviving an attempt pays the target.
|
||||
|
||||
## What the codebase already gives us (verified 2026-07-13)
|
||||
|
||||
| Need | Existing machinery |
|
||||
|---|---|
|
||||
| Currency + escrow | `EuroPlugin.Debit/Credit` (`euro.go:364,:395`), duel escrow pattern (`adventure_duel.go:422,:529,:667`), community pot (`adventure_rival.go:193-241`) |
|
||||
| "Is X out right now?" | `getActiveExpedition` (`dnd_expedition.go:215`), roster already pushed to Pete every 2 min |
|
||||
| Mid-run combat injection | `runHarvestInterrupt` (`dnd_expedition_combat.go:121`) — picks enemy, surprise nick, `runZoneCombatRoster`, full win/retreat/death close-out. **This is the template.** |
|
||||
| Safe-delivery gating | ambient ticker's CAS claim + `hasActiveCombatSession` + quiet-window checks (`expedition_ambient.go:97,:109,:156`) |
|
||||
| Grunt→boss ladder | Arena tiers 1–5 (`adventure_arena_monsters.go`), `arenaMonsterToTemplate` (`:251`); zone pools tag `IsElite` |
|
||||
| Games-room announce | `gamesRoom()` + world-boss announce helpers (`adventure_worldboss.go:528-566`) |
|
||||
| Respond-within-window | duel challenge window + atomic single-winner claim (`adventure_duel.go:43,:85`) |
|
||||
| Survival payout kit | `euro.Credit`, `rollAdvTreasureDrop`, `consumableCache`, renown, world-boss payout bundle (`adventure_worldboss.go:466-493`) |
|
||||
| Pete pipe | durable fact queue, bearer auth, event-type taxonomy; **strictly one-way gogobee→Pete today** |
|
||||
|
||||
Hard constraint (Pete `internal/web/roster.go:23-25`): *"Pete has no route back
|
||||
into the game box's network and we are not opening one."* We honor that: the web
|
||||
storefront stores orders in Pete's DB and **gogobee polls Pete** (gogobee stays
|
||||
the only initiator, same tailnet + bearer pattern as ingest, just a GET).
|
||||
|
||||
## Design
|
||||
|
||||
### Contract lifecycle
|
||||
|
||||
```
|
||||
placed ──announce──▶ open (help/"help" window, default 60 min)
|
||||
│ │
|
||||
│ ├──▶ delivered (next safe tick: target active, no combat,
|
||||
│ │ not in briefing/recap quiet window)
|
||||
│ │ ├── target survives → SURVIVED payout + unseal buyer
|
||||
│ │ └── target defeated → DOWNED consequences
|
||||
│ └──▶ fizzled (expedition ended first → refund minus rake)
|
||||
└── rejected at claim time (funds/eligibility) → never announced
|
||||
```
|
||||
|
||||
- New table `mischief_contracts`: id, buyer_id, target_id, tier, fee, status
|
||||
(`pending|open|delivered|fizzled|rejected`), signed (bool), escalation_count,
|
||||
blessing json, source (`matrix|web`), created_at, window_ends_at, resolved_at.
|
||||
Status transitions via conditional UPDATE (CAS), mirroring `deliverAmbient`.
|
||||
- One live contract per target at a time; delivery fires from a sibling of the
|
||||
ambient ticker after `window_ends_at`.
|
||||
|
||||
### The four tiers — PRICED (M0 done 2026-07-13)
|
||||
|
||||
Monster strength is **relative to the target**: pull from the target's own
|
||||
bracket **zone pools** (grunt/mob = non-elite, elite = `IsElite`, boss = the
|
||||
zone boss). NOT the arena ladder — arena `BaseLethality` is a legacy death
|
||||
chance; arena T5 one-shots L20s and would make every tier an execution.
|
||||
|
||||
**M0 survival sweep** (n=2000/cell, `SimulateCombat` via the class-balance
|
||||
harness builds at full HP, zone-pool monsters, ambush nick on elite/boss;
|
||||
throwaway harness left skip-gated at
|
||||
`internal/plugin/mischief_pricing_sweep_test.go`, env `MISCHIEF_SWEEP=1`):
|
||||
|
||||
| target build | grunt | mob (3 chained) | elite | boss |
|
||||
|---|---|---|---|---|
|
||||
| paladin L1 | 99.8% | 98.2% | 57.6% | 15.3% |
|
||||
| mage L4 | 100% | 97.8% | 49.5% | 0.0% |
|
||||
| mage L8 evoc | 100% | 100% | 56.5% | 0.4% |
|
||||
| fighter L12 champ | 100% | 100% | 100% | 99.9% |
|
||||
| rogue L20 AT (prosolis) | 100% | 100% | 100% | 35.9% |
|
||||
| cleric L20 life (holymachina) | 100% | 100% | 100% | 14.1% |
|
||||
|
||||
Reading: grunt/mob are pure theater (+HP/supply attrition); elite is a real
|
||||
coin-flip for at-bracket targets and trivial for overleveled martials; boss is
|
||||
expedition-ending for casters at any level and a genuine 1-in-3 scare even for
|
||||
the L20 rogue. Caveats: harness builds ≠ real sheets, full-HP-at-delivery is
|
||||
optimistic (mid-run wounds raise real danger), no pets/party/consumables.
|
||||
|
||||
**Prod economy snapshot** (2026-07-13, 23 wallets): median balance ≈ €500;
|
||||
whales prosolis €180k / holymachina €149k / nonk €62k hold 89% of all money.
|
||||
Daily earn: whales ~€1.8–2.3k, mid-tier €20–500, casuals <€10. Reference
|
||||
sinks: lottery ticket €100, duel escrow €1.5–5k, daily share ~€455, endgame
|
||||
shop items €11–45k. Only 5 characters exist as targets (two L20s, L4, two
|
||||
L1s) — the whales are both the richest buyers *and* the only high-bracket
|
||||
targets, so boss-tier is effectively their PvP toy; casuals buy theater.
|
||||
|
||||
| Tier | Fee | Signed (+25%) | Survival payout (of fee) | Extras on survival |
|
||||
|---|---|---|---|---|
|
||||
| grunt | €40 | €50 | 40% → €16 | flavor line |
|
||||
| mob | €100 | €125 | 50% → €50 | treasure roll (standard) |
|
||||
| elite | €350 | €438 | 65% → €228 | treasure roll (elite) + renown |
|
||||
| boss | €1,200 | €1,500 | 75% → €900 | elite treasure + consumable cache + renown + Pete milestone |
|
||||
|
||||
Rationale: grunt under the lottery-ticket impulse line so anyone can play;
|
||||
mob = exactly one lottery ticket; elite ≈ one active day of mid-tier earnings
|
||||
(a considered purchase, and its ~50% at-bracket survival odds are honest
|
||||
stakes); boss in duel-stake territory — whale money, priced like the "end an
|
||||
expedition" button it is against caster targets. Escalation cost = the
|
||||
tier-delta fee; blessings €25 flat.
|
||||
|
||||
Anti-collusion invariant: **total payout (fee + escalations) capped at 75%**
|
||||
in every case, so collusion is strictly dominated by `!baltransfer`, which is
|
||||
free. No danger multiplier needed — the cap does all the work.
|
||||
|
||||
Money flow: survival payout to target, remainder → community pot. Defeat →
|
||||
entire fee to community pot (never back to the buyer — glory only). Fizzle →
|
||||
90% refund, 10% rake to pot.
|
||||
|
||||
Boss-tier friction (0–14% caster survival means "boss ≈ certain maiming"):
|
||||
max 1 boss contract per target per week, on top of the global limits below.
|
||||
|
||||
### Death policy (recommendation: mischief maims, it doesn't murder)
|
||||
|
||||
`runHarvestInterrupt`'s loss path perma-kills (`abandonZoneRun` +
|
||||
`forcedExtractExpedition` + mark dead). For a *purchased* attack that lands
|
||||
while the victim is offline, perma-death feels like paid murder, not mischief.
|
||||
Recommended: on defeat, apply the world-boss HP floor (`worldBossFloorHP`
|
||||
pattern), force-extract the expedition (run-loss seam already exists), drop a
|
||||
chunk of un-banked expedition coins/supplies, +threat. Character lives.
|
||||
**Open decision** — could make boss tier lethal for stakes, but then it needs an
|
||||
opt-in (hardcore flag) or it's a griefing lever.
|
||||
|
||||
### Anonymity + the unseal twist
|
||||
|
||||
Default: contracts are anonymous — "someone in town has put coin on
|
||||
<target>'s head." Buyer can pay +25% to *sign* it (taunt rights).
|
||||
**If the target survives, the contract is unsealed** — Pete names the buyer in
|
||||
the survival bulletin. Risk of exposure is the natural brake on casual griefing,
|
||||
and it makes survival announcements delicious.
|
||||
|
||||
### The games-room window (help or "help")
|
||||
|
||||
On placement, announce in `GAMES_ROOM` (world-boss style) + Pete priority beat.
|
||||
During the window (default 60 min):
|
||||
|
||||
- `!mischief bless @target` — pay a small fee (~€15) for temp HP / +AC on the
|
||||
incoming fight (well-rested-style bundle). Stacks, capped (say 3 blessings).
|
||||
- `!mischief escalate @target` — pay ~half the next-tier delta to bump the
|
||||
contract one tier, max one step total, boss can't escalate. Escalation money
|
||||
joins the payout escrow — piling on raises the target's survival jackpot.
|
||||
- Victim gets a TwinBee DM (first person, per voice rules): word has reached
|
||||
them that someone wants them dead. Pure flavor — expeditions are autonomous —
|
||||
but it lets them rally blessings.
|
||||
|
||||
Blessing/escalation state lives on the contract row (real rows, no phantom
|
||||
per-fight state — lesson from the companion free-lunch bugs).
|
||||
|
||||
### Delivery mechanics
|
||||
|
||||
New `runMischiefInterrupt`, modeled line-for-line on `runHarvestInterrupt`:
|
||||
pick monster per tier table → apply blessings to the roster → surprise nick
|
||||
(attacker's privilege) → `runZoneCombatRoster(fightRoster(target), …)` → custom
|
||||
close-out. Do **not** call `recordZoneKill`/advance zone state — the fight is
|
||||
extrinsic to the dungeon. Party seats fight together and earn seat XP as usual;
|
||||
the survival purse goes to the target (leader).
|
||||
|
||||
Fizzle: if the expedition ends before delivery, refund fee minus 10% rake
|
||||
(deadpan Pete line: the monster arrived to an empty dungeon).
|
||||
|
||||
### Rate limits & eligibility (anti-grief)
|
||||
|
||||
- Target must have an active expedition **and** be level ≥ 3 (or similar floor).
|
||||
- One live contract per target; per-target cooldown 24 h after resolution.
|
||||
- Per-buyer cap: 2 contracts/day. Can't target yourself. Escrow debited at
|
||||
placement via `euro.Debit` (respects the debt floor for free).
|
||||
- Boredom-ticker auto-expeditions: **targetable** (they're real runs and it's
|
||||
funny), but revisit if it feels bad in practice.
|
||||
- Opt-out: none for v1 — being in the world means being in the world — but keep
|
||||
the decision explicitly revisitable. News anonymization (`!news optout`)
|
||||
still applies to Pete-facing names as it does today.
|
||||
|
||||
### Pete web storefront (the reverse pipe)
|
||||
|
||||
**Decision (2026-07-13): mischief UI requires Authentik sign-in on Pete.**
|
||||
Pete's OIDC layer already exists (`[web.auth]`, disabled by default) and the
|
||||
callback already parses `preferred_username` from the ID token
|
||||
(`auth.go:236-250`) — it just isn't persisted into the `pete_session` cookie.
|
||||
**VERIFIED 2026-07-13** on parodia.dev (`matrix-mas-1`,
|
||||
`/home/reala/matrix/compose/mas/config.yaml`): MAS imports localpart with
|
||||
`action: require, template: '{{ user.preferred_username }}'` — so
|
||||
**Authentik username == Matrix localpart**, guaranteed. Identity is free:
|
||||
add PreferredUsername to `SessionUser`, gogobee maps it to
|
||||
`@<username>:<server>` (lowercase it — Matrix localparts must be lowercase,
|
||||
Authentik usernames may not be). No link codes.
|
||||
|
||||
The one-way *network* rule (`roster.go:23-25`) stays intact — both new data
|
||||
flows are gogobee-initiated:
|
||||
|
||||
- **Balances out (push):** gogobee already pushes a roster snapshot every
|
||||
2 min; add euro-balance entries for linked users on the same tick. Pete
|
||||
renders "~€X" and greys out unaffordable tiers. Advisory only; up to 2 min
|
||||
stale is fine.
|
||||
- **Orders in (poll):** signed-in buyer POSTs the order form → `mischief_orders`
|
||||
row in pete.db keyed by preferred_username, status `pending`. gogobee's
|
||||
peteclient grows a poll loop: `GET /api/mischief/pending` (bearer) every
|
||||
30 s, claims each order (`POST /api/mischief/claim`, idempotent on order
|
||||
GUID), then validates in-game.
|
||||
- **Money truth lives only in gogobee:** the authoritative check is
|
||||
`euro.Debit` at claim time. A stale web balance just means an occasional
|
||||
"order bounced — insufficient funds" status (rendered from a fact) + TwinBee
|
||||
DM to the buyer. Pete never writes a balance, so no double-spend surface.
|
||||
|
||||
Order targets: roster board (already live on `/adventure`) grows a "send
|
||||
trouble" button per entry (roster token identifies the target — already
|
||||
stable + non-deanonymizing). Pete renders order status from resulting facts,
|
||||
never from live game state.
|
||||
|
||||
Ops note: enabling `[web.auth]` on prod Pete needs the Authentik OAuth client
|
||||
provisioned + config.toml edit on the box (config is box-only).
|
||||
|
||||
### New Pete event types (deploy Pete FIRST — unknown types 400 → park forever)
|
||||
|
||||
`mischief_contract` (priority: a hit is out, tier, anonymous-or-signed),
|
||||
`mischief_survived` (priority: target thwarted it — unseal buyer here),
|
||||
`mischief_downed` (priority), `mischief_fizzled` (bulletin),
|
||||
`mischief_link` (internal, no render — or handle out-of-band). Deadpan voice
|
||||
throughout; check `adventure_flavor_*.go` for reusable lines before writing new
|
||||
flavor (standing rule).
|
||||
|
||||
## Phases
|
||||
|
||||
- **M0 — price the tiers. DONE 2026-07-13** (single-fight sweep + prod
|
||||
economy snapshot; see tier table above). Follow-up for M1 close-out: re-run
|
||||
the sweep through the *real* delivery path once `runMischiefInterrupt`
|
||||
exists, since full-HP single fights understate mid-run danger.
|
||||
- **M1 — core engine, Matrix-only. DONE 2026-07-13 (uncommitted, not deployed).**
|
||||
`adventure_mischief.go` (tiers/pricing/persistence/eligibility/`!mischief`),
|
||||
`adventure_mischief_deliver.go` (ticker + `runMischiefInterrupt` + close-outs),
|
||||
`mischief_contracts` table, Pete's 4 `mischief_*` event types. 17 tests,
|
||||
4 of them end-to-end through the real fight + euro + extraction.
|
||||
|
||||
Decisions taken during implementation (all inside the plan's intent):
|
||||
- **Bracket monster-selection promoted out of the M0 test** into prod
|
||||
(`mischiefBracketZone`/`mischiefMonsters`), so the sweep and the live
|
||||
delivery now drive *the same* selection code — the fee table can't drift
|
||||
away from the fight it priced.
|
||||
- **Survival is read off the target's HP, not `PlayerWon`.** The engine's
|
||||
timeout is a retreat, not a lethal blow: a target who ran out the clock
|
||||
with HP left held the thing off, and a bought monster that merely outlasted
|
||||
them hasn't earned a maiming. (M0 counted `!PlayerWon` as death, so real
|
||||
survival rates are a touch *better* than priced. Pricing stands.)
|
||||
- **No-perma-death extended to the whole party** (`floorMischiefRoster`, run
|
||||
on BOTH outcomes). A leader can win a fight their friend went down in, and
|
||||
the delivery skips `closeOutZoneWin` — without the floor, the member is left
|
||||
alive at 0 HP, which every `HPCurrent <= 0` gate reads as broken.
|
||||
- **One-live-contract-per-target is a partial UNIQUE INDEX**, not a read-then-
|
||||
write check: placement holds only the *buyer's* lock, so two buyers racing at
|
||||
one victim would both pass an in-code check. The loser is refunded.
|
||||
- **Stale-delivery sweep** (`delivering` + 15 min grace → full refund). Without
|
||||
it a crash mid-fight strands the row: target permanently un-targetable,
|
||||
buyer's money gone.
|
||||
- All contract timestamps bind as Go `time.Time` — never `CURRENT_TIMESTAMP`.
|
||||
The driver stores RFC3339 (`2026-07-14T03:48:52Z`); a SQL-side stamp writes
|
||||
`2026-07-14 03:48:52`, and the two compare lexicographically wrong.
|
||||
- Fizzle DMs + rake, 90% refund; unstageable/stranded contracts refund 100%
|
||||
(our fault, not a bet they lost).
|
||||
|
||||
**M1 close-out sweep DONE 2026-07-13** (`mischief_delivery_sweep_test.go`,
|
||||
`MISCHIEF_SWEEP=1`, n=400/cell, 108 cells through the REAL delivery path:
|
||||
`runMischiefInterrupt` → `runZoneCombatRoster`). Arms: entry HP 100/70/40%
|
||||
(100% = control, reproduces M0 through the new path) × wards 0/3 on elite+boss.
|
||||
|
||||
| target | tier | 100% HP | 70% HP | 40% HP | 70% +3 wards |
|
||||
|---|---|---|---|---|---|
|
||||
| paladin L3 | elite | 84.8% | 73.2% | 62.7% | 80.5% |
|
||||
| paladin L3 | boss | 87.8% | 45.8% | 8.0% | 88.0% |
|
||||
| mage L4 | elite | 66.0% | 29.5% | 4.0% | 64.0% |
|
||||
| mage L8 evoc | elite | 90.2% | 72.2% | 15.0% | 84.2% |
|
||||
| mage L8 evoc | boss | 6.5% | 2.0% | 0.2% | 7.8% |
|
||||
| fighter L12 | boss | 88.2% | 48.0% | 6.8% | 89.8% |
|
||||
| rogue L20 | boss | 19.2% | 2.0% | 0.0% | 18.5% |
|
||||
| cleric L20 | boss | 42.0% | 6.0% | 0.0% | 42.8% |
|
||||
|
||||
(grunt/mob: 100% for everyone except mage L4, who can lose a wounded mob.)
|
||||
|
||||
Three findings:
|
||||
- **The M0 table was priced on a fight we don't deliver.** The control arm
|
||||
diverges from M0 in BOTH directions: up where an engine timeout now counts as
|
||||
survival (paladin boss 15→88%), and *down* where the turn engine loops a
|
||||
boss's full multiattack profile and `SimulateCombat` doesn't (fighter boss
|
||||
99.9→88%, rogue boss 36→19%) — see [[project_sim_multiattack_gap]]. Fees kept
|
||||
(the tiers still do what they were priced to do); this table is now the
|
||||
reference.
|
||||
- **Wounding is the dominant variable, and M0 was blind to it.** Boss at a
|
||||
realistic mid-run 70% HP collapses across the board (fighter 88→48, cleric
|
||||
42→6, rogue 19→2); at 40% it is near-zero for everyone. Boss really is the
|
||||
"end their expedition" button, as priced.
|
||||
- **Wards were too cheap** (see M2 below): 3 of them buy ~+40pp.
|
||||
- **M2 — the window. DONE 2026-07-13.** `!mischief bless @user` (€25, cap 3,
|
||||
+10% MaxHP temp HP each) · `!mischief escalate @user` (pays the tier delta,
|
||||
one step, boss is the ceiling) · victim DM on placement · escalator unsealed
|
||||
alongside the buyer on survival. 6 new tests (22 total).
|
||||
|
||||
Decisions taken during implementation:
|
||||
- **Blessing fee is a pure sink** (community pot), never a purse top-up. If a
|
||||
ward raised the payout basis, warding a friend would become a money-routing
|
||||
move; buying them HP can't be.
|
||||
- **Ward price scales with the contract** — `max(€25, 10% of fee)`: grunt/mob
|
||||
€25, elite €35, boss €120 (€360 to cover someone completely). The close-out
|
||||
sweep measured 3 wards at ~+40pp (fighter vs boss at 70% HP: 48→90%), so a
|
||||
flat €25 let the room halve a €1,200 boss contract for €75 — pocket change
|
||||
against the tier the whole economy rests on. Reads the contract's *current*
|
||||
basis, so an escalation raises the price of saving its target too. The €25
|
||||
floor is knowingly above-market at grunt (3 wards > the €40 fee): nothing
|
||||
needs warding against theatre, and the floor exists to keep a ward an impulse.
|
||||
- **Escalation raises `fee` (the payout basis) AND `paid` together**, so the
|
||||
75% cap and "purse < outlay" survive an escalation untouched — asserted.
|
||||
- **Escalating into boss answers to the boss-per-target-per-week cap.**
|
||||
Otherwise the cap is bought around for the price of an elite plus the delta.
|
||||
- **Both window commands are CAS-then-refund**, like placement: the ward cap and
|
||||
the one-step limit live in the UPDATE's WHERE clause, because a scramble is
|
||||
exactly when four people press the button in the same second.
|
||||
- **The delivery re-reads the contract AFTER claiming it.** The due-sweep's copy
|
||||
is stale by construction — the window keeps writing to an open row up to the
|
||||
claim, so a last-second escalation was paid for and then delivered the *old*
|
||||
tier's monster. The claim is the fence. (Fixed + regression test.)
|
||||
- **The ward is temp HP** (the well-rested mechanism), added to whatever cushion
|
||||
the target already carried and subtracted again after the fight — a bought ward
|
||||
must not eat a home long rest. It refreshes per link of the mob chain; that only
|
||||
affects the one tier the sweep priced as theatre.
|
||||
- Blessers are named on the spot (helping is proud); escalators are anonymous
|
||||
until the survival unseals them, exactly like the buyer.
|
||||
- Fizzled/unstageable contracts do **not** refund blessers. Their €25 was charity,
|
||||
and it went to the pot. Revisit if it stings in practice.
|
||||
- Fixed a pre-existing wall-clock flake in the M1 fizzle test: it drove
|
||||
`fireMischiefDeliveries` with `time.Now()`, which refuses to run inside the
|
||||
±1 h quiet windows around the 06:00 briefing and 21:00 recap.
|
||||
- **M3 — Pete storefront (1 session, cross-repo).** Enable + extend OIDC
|
||||
(persist preferred_username; provision Authentik client), orders table +
|
||||
form + status rendering, balance push, gogobee poll/claim loop. First
|
||||
visitor-facing write path on Pete — auth-gated + rate-limited per user.
|
||||
(MAS localpart == Authentik preferred_username: verified 2026-07-13.)
|
||||
- **M4 — polish.** Notoriety/renown for buyers, survival streaks → milestones,
|
||||
digest lines, boredom-expedition policy review, tune fees from live data.
|
||||
|
||||
## Decisions — ALL CONFIRMED by reala 2026-07-13
|
||||
|
||||
1. **No perma-death anywhere** — mischief maims: HP floor + force-extract +
|
||||
un-banked loot/supply loss + threat bump. Boss tier included.
|
||||
2. **Anonymous by default; survival unseals the buyer** in Pete's bulletin;
|
||||
+25% to sign openly.
|
||||
3. **Fizzle refund 90%**, 10% rake to community pot.
|
||||
4. **Targets: level ≥ 3 with an active expedition**; boredom auto-runs are
|
||||
fair game. One live contract per target, 24 h post-resolution cooldown,
|
||||
2 contracts/day per buyer, 1 boss/target/week.
|
||||
5. **Fee/payout table as priced above** (M0 sweep + prod economy).
|
||||
|
||||
## Standing-rule checklist
|
||||
|
||||
- No new `dnd_`-prefixed files/tables → `adventure_mischief*.go`, `mischief_*` tables.
|
||||
- TwinBee first-person; Pete deadpan third-person announcer.
|
||||
- Plan doc stays working-tree only.
|
||||
- Pete deploys before gogobee whenever event types change.
|
||||
- Sim sweeps: control arm + n≥750; run heavy sweeps on Parodia/millenia.
|
||||
- Any loader/bootstrap added must keep its backfill as a one-shot bootstrap.
|
||||
@@ -1,267 +0,0 @@
|
||||
# Revisit / Backtrack Navigation — Design Plan
|
||||
|
||||
Lets players walk back to previously visited rooms within an active zone
|
||||
run to re-harvest, re-fork (e.g. after acquiring a key), or pick up
|
||||
something they skipped. Forward-only navigation has been the rule since
|
||||
Phase G; this plan retires that assumption deliberately.
|
||||
|
||||
## Goals
|
||||
|
||||
- Re-harvest depleted nodes after a long rest (primary use case).
|
||||
- Allow general exploration freedom — wander back, look around.
|
||||
- Re-pick a fork after acquiring a key or changing strategy.
|
||||
- Preserve the existing pacing pressure (threat clock, SU drain) but
|
||||
make backtracking *cheaper* than fresh exploration to reflect that
|
||||
the route is already known.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Teleporting to arbitrary discovered rooms.
|
||||
- Backtracking during `!expedition run` autopilot (autopilot is
|
||||
forward-only by design).
|
||||
- Re-fighting cleared encounters or re-arming disarmed traps.
|
||||
|
||||
## Player-facing surface
|
||||
|
||||
- **`!revisit <N>`** — walks one edge from the current node toward a
|
||||
previously-visited node. `N` is the 1-indexed room number printed in
|
||||
`!map`'s Path strip.
|
||||
- Must be in `VisitedNodes`.
|
||||
- Must be adjacent to `CurrentNode` via an edge in the zone graph
|
||||
(forward or reverse direction; graph edges are directed but we
|
||||
allow reverse traversal for revisit).
|
||||
- Costs threat and SU at a reduced rate (see Cost Model).
|
||||
- No combat re-roll, no trap re-arm in cleared rooms.
|
||||
|
||||
- **`!map`** — already shows the numbered Path strip; players read N
|
||||
from there.
|
||||
|
||||
- **`!zone advance`** post-revisit — unchanged. From any node, picks
|
||||
the first outgoing edge (or shows the fork prompt). Re-forking a
|
||||
previously-resolved fork node clears the prior choice silently.
|
||||
|
||||
## Cost model
|
||||
|
||||
> **CORRECTED 2026-07-09 during R1.** The table below originally claimed a
|
||||
> fresh `!zone advance` costs +2 threat / −1.0 SU. It does not. Verified at
|
||||
> HEAD: **movement itself is free.** There is no per-room tick anywhere.
|
||||
> - **Supplies burn per *day*,** not per room — `applyDailyBurn` is called
|
||||
> only from `dnd_expedition_cycle.go:108` (the day rollover),
|
||||
> `dnd_expedition_extract.go:52`, and region transit. Never from advance.
|
||||
> - **Threat comes from *combat*,** not from walking:
|
||||
> `applyRoomCombatThreatForUser` (+5 normal, +8 elite/boss) fires from the
|
||||
> three combat-resolution sites, plus daily drift, harvest noise (+2), and
|
||||
> ambient. `zoneCmdAdvance` / `advanceOnce` touch neither clock.
|
||||
>
|
||||
> This inverts R2's central premise. A revisited room fires no combat
|
||||
> (terminal `CombatSession` rows gate re-entry), so backtracking is already
|
||||
> free — the design problem is not *discounting* a cost, it's that there is
|
||||
> **no pacing pressure to discount**. Whatever R2 charges is a net-new cost.
|
||||
|
||||
| Move | Threat tick | SU tick |
|
||||
|------|-------------|---------|
|
||||
| Fresh-room `!zone advance` | **0** (actual) | **0** (actual) |
|
||||
| Combat resolved in a room | +5, +8 elite/boss | 0 |
|
||||
| Day rollover | drift (mood-scaled) | −daily burn |
|
||||
| `!revisit <N>` (one edge) | **TBD — see below** | n/a (no per-move SU exists) |
|
||||
|
||||
**DECIDED 2026-07-09 — option (1), +1 threat per revisit edge.** Shipped in
|
||||
R2 as `revisitThreatCost`. Standalone (non-expedition) zone runs have no
|
||||
threat clock and so pay nothing. A `revisitSiegeGuard` refuses the move at
|
||||
threat ≥ 99: siege is one-way sticky, and a player must not be able to strand
|
||||
their own expedition on a move they made to go pick up a herb.
|
||||
|
||||
Options as they were weighed, cheapest first:
|
||||
1. **Charge +1 threat per revisit edge.** One `applyThreatDelta` call,
|
||||
mirrors "harvest noise". Reads as "you stir up attention doubling back."
|
||||
Cheap, honest, and it's the only clock revisit can plausibly touch.
|
||||
2. **Charge nothing.** Backtracking is already free of combat; the real
|
||||
cost is the day clock, which a long detour burns anyway. Simplest, and
|
||||
arguably correct — the pacing pressure is the multi-day supply budget,
|
||||
not the step count.
|
||||
3. **Charge a fractional day.** Rejected: the day counter is the spine of
|
||||
every milestone, digest and anchored event. Don't make it fractional.
|
||||
|
||||
Recommend **(1)** at +1: it's the smallest lever that makes a detour a real
|
||||
choice, and it rides an existing, tested seam.
|
||||
|
||||
## State model
|
||||
|
||||
The hard problem: `CurrentRoom` is currently derived as
|
||||
`len(VisitedNodes)-1` (dnd_zone_run.go:377), which assumes
|
||||
monotonically-growing visit history. Backtracking breaks that.
|
||||
|
||||
### Schema changes
|
||||
|
||||
- **`VisitedNodes`** — unchanged semantics: ordered set of nodes ever
|
||||
entered, first-entry order. Stays append-only when entering a *new*
|
||||
node; revisits do not append.
|
||||
- **`CurrentNode`** — already the source-of-truth for "where am I."
|
||||
- **`CurrentRoom`** — repurpose from `len(VisitedNodes)-1` to "index of
|
||||
CurrentNode within VisitedNodes" (the room's first-entry index, which
|
||||
is the path-relative label the player sees).
|
||||
- **`RoomsTraversed`** (NEW, int column on `dnd_zone_run`) — monotonic
|
||||
step counter. Drives threat/SU ticks. Bootstrapped to
|
||||
`len(VisitedNodes)` for existing rows.
|
||||
|
||||
### Audit pass required
|
||||
|
||||
Every read of `r.CurrentRoom` and `len(r.VisitedNodes)` needs to be
|
||||
classified:
|
||||
|
||||
- **"Path index" reads (keep as CurrentRoom)**: display headers
|
||||
("Room 3/7"), encounter ID derivation (`encounterIDForRoom`), enemy
|
||||
salt seeds, harvest room keys, status/abandon strings, camp room
|
||||
index.
|
||||
- **"Progress counter" reads (switch to RoomsTraversed)**: threat
|
||||
clock ticks, supplies ticks, ambient narration cadence, autopilot
|
||||
preflight, fatigue/exhaustion accrual if any.
|
||||
|
||||
Concrete callsite list will be produced during implementation; estimate
|
||||
~10–15 callsites total.
|
||||
|
||||
## Fork re-pick
|
||||
|
||||
Re-entering a fork node clears its resolved choice in `node_choices`
|
||||
(or equivalent). Next `!zone advance` shows the fork prompt fresh,
|
||||
evaluated against current inventory (so a newly-acquired key unlocks
|
||||
previously-locked edges).
|
||||
|
||||
Silent — no warning. Graph-back navigation means the player has
|
||||
walked back through their previously-chosen path; nothing in
|
||||
`VisitedNodes` is destroyed; the alternate branch they previously
|
||||
took is still revisitable. There is no "discard" semantics.
|
||||
|
||||
## RoomsCleared semantics
|
||||
|
||||
Idempotent — exiting a room a second time doesn't re-append to
|
||||
`RoomsCleared`. The "advanced past" flag is sticky.
|
||||
|
||||
## Preflight checks (rejections)
|
||||
|
||||
`!revisit` rejects with a clear DM when:
|
||||
|
||||
- Target room is not in `VisitedNodes` ("You haven't been there yet.")
|
||||
- Target equals `CurrentNode` (no-op)
|
||||
- Target is not adjacent to `CurrentNode` via any graph edge
|
||||
("Room X isn't directly connected — backtrack one step at a time.")
|
||||
- Active combat session in current room
|
||||
("Finish the fight first.")
|
||||
- Threat clock would tick past max (use existing camp-eligibility
|
||||
preflight pattern)
|
||||
- SU would drop below survival floor (same)
|
||||
- Character is dead / expedition is paused (same gates as `!zone advance`)
|
||||
- Autopilot run is active
|
||||
|
||||
## Rollout phases
|
||||
|
||||
Tentative ordering. Each phase ships independently.
|
||||
|
||||
### R1 — schema + audit ✅ **DONE 2026-07-09** (branch `n1-restoration`)
|
||||
|
||||
Shipped: `rooms_traversed` column + `bootstrapRoomsTraversed` backfill;
|
||||
`CurrentRoom` re-derived via `pathIndexOf(VisitedNodes, CurrentNode)`;
|
||||
`appendVisited` / `appendClearedRoom` made set-semantic; `narrationCadence`
|
||||
routed off `RoomsTraversed`; `advanceZoneRunNode` now returns the moved-to
|
||||
path index. Tests in `zone_revisit_r1_test.go`. Full suite green. No
|
||||
player-visible behavior change.
|
||||
|
||||
**Audit outcome — the callsite split was smaller than estimated (~10–15).**
|
||||
Almost every `CurrentRoom` read is a *path index* and correctly stays put:
|
||||
enemy/trap salts (`pickZoneEnemy`, `pickZoneTrap`, `rollTrapDamage`), loot
|
||||
RNG seed, `encounterIDForRoom`, harvest room keys, camp `RoomIndex`, map
|
||||
render, "Room X/Y" headers. Keying those on the node's first-entry index is
|
||||
exactly what makes a revisited room resolve to *the same room*.
|
||||
|
||||
Only two reads were progress-shaped:
|
||||
- `narrationCadence` → now `RoomsTraversed-1`, so a backtracking player
|
||||
draws fresh flavor instead of replaying the lines they read on the way in.
|
||||
- `nextIdx := run.CurrentRoom + 1` (`dnd_zone_cmd_graph.go`) — not a
|
||||
progress read but a latent bug: "+1" only labels correctly while the
|
||||
player is at the frontier. `advanceZoneRunNode` now returns the true index.
|
||||
|
||||
**Nothing to route off `RoomsTraversed` for threat/SU** — there are no
|
||||
per-room ticks to route (see the corrected Cost model above). The counter
|
||||
currently drives narration cadence and stands ready for R2's cost decision.
|
||||
|
||||
Two behaviors were hardened in passing, both no-ops under forward-only
|
||||
navigation but load-bearing the moment R2 lands:
|
||||
- `VisitedNodes` is an ordered **set** — a re-entered node is not
|
||||
re-appended, so room numbers never renumber under the player.
|
||||
- `RoomsCleared` is **idempotent** — exiting a room twice can't inflate it
|
||||
past `TotalRooms` and skew the "N/M rooms" renders.
|
||||
|
||||
### R2 — `!revisit` command ✅ **DONE 2026-07-09** (branch `n1-restoration`)
|
||||
|
||||
Shipped: `!revisit <N>` (alias `!zone revisit`), `adjacentNodes` reverse-edge
|
||||
traversal, full preflight, +1 threat, `!map` now prints a **Back to:** strip
|
||||
of legal targets. Fork re-pick still deferred to R3 — revisit refuses while a
|
||||
fork prompt is pending, because both `!zone advance` and `!zone go` resolve
|
||||
`node_choices` without checking which node the player is standing on.
|
||||
|
||||
**"No combat/trap re-trigger logic needed" was wrong — and it was an exploit.**
|
||||
Terminal `CombatSession` rows gate *only* Elite and Boss rooms, at the doorway
|
||||
check in `advanceOnceWithOpts`. An Exploration room re-enters
|
||||
`resolveCombatRoom` unconditionally and a Trap room re-arms. Left alone,
|
||||
`!revisit` would have been an infinite farm: step back one room, `!zone
|
||||
advance`, repeat for loot and XP. Fixed with a `RoomIsCleared(CurrentRoom)`
|
||||
early-return at the top of `resolveRoom` — a no-op forward-only, since advance
|
||||
clears a room only *after* resolving it. Guarded by
|
||||
`TestRevisitedRoom_DoesNotReResolve` and `TestFreshRoom_StillResolves`.
|
||||
|
||||
**Autopilot needed a stopgap, sooner than R5 planned.** Autopilot has no
|
||||
on/off switch — `tryAutoRun` is ticker-driven for every active expedition on a
|
||||
2h CAS cooldown. Without intervention the background walker marches the player
|
||||
straight back out of the room they just revisited, and the whole feature reads
|
||||
as broken. `grantAutorunGrace` stamps `last_autorun_at` on revisit, buying one
|
||||
full cooldown window. **R5 still owes the real policy decision** (refuse to
|
||||
auto-walk from a non-frontier node, vs. walk back to the frontier first).
|
||||
|
||||
Tests in `zone_revisit_r2_test.go`. Full suite green.
|
||||
|
||||
### R3 — fork re-pick
|
||||
|
||||
- On revisit landing into a fork node, clear `node_choices` for that
|
||||
node.
|
||||
- Next advance re-prompts.
|
||||
- Tests: lock-with-key acquired between two fork visits, alternate
|
||||
branch selection, no-change re-pick.
|
||||
|
||||
### R4 — UX polish
|
||||
|
||||
- DM line on revisit explaining the cost ("You retrace your steps to
|
||||
Room 3 (the fork). Threat +1, SU −0.5.").
|
||||
- Surface `!revisit` in `!help` and `!zone` help blocks.
|
||||
- Ambient/narration audit: any "you press deeper into the dungeon"
|
||||
lines that no longer fit when player is backtracking.
|
||||
|
||||
### R5 — autopilot guard
|
||||
|
||||
- `!expedition run` refuses to start while at a non-frontier node?
|
||||
Or auto-walks back to frontier first? Decide based on R1–R4 feel.
|
||||
Likely: refuse with a "use !zone advance to return to the frontier
|
||||
first" hint.
|
||||
|
||||
## Open questions (lower priority)
|
||||
|
||||
- Should there be a small flavor-only narrative beat the first time
|
||||
the player backtracks in an expedition? ("TwinBee notes you doubling
|
||||
back — there's no shame in it. Sometimes the answer was in a room
|
||||
you'd already passed.")
|
||||
- Does a babysitter's safe-rest affect revisit cost? (Probably not —
|
||||
babysitter is a camp upgrade, revisit is movement.)
|
||||
- Multi-region expeditions — does revisit work across region
|
||||
boundaries within the same expedition? (Initial answer: no, only
|
||||
within the current zone run; cross-region travel is its own seam.)
|
||||
|
||||
## Effort estimate
|
||||
|
||||
Comparable to a small Phase pass (G6-sized). Roughly:
|
||||
|
||||
- R1: 1 session (schema bump + audit + tests).
|
||||
- R2: 1 session.
|
||||
- R3: 0.5 session.
|
||||
- R4: 0.5 session.
|
||||
- R5: 0.5 session.
|
||||
|
||||
Total: ~3–4 working sessions.
|
||||
@@ -1,297 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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)
|
||||
}
|
||||
|
||||
// Resolve room state (is it encrypted, who is in it) from the server on first
|
||||
// use, since there is no /sync to backfill it. Must be installed before the
|
||||
// first BotClient() call: makeClient copies as.StateStore into the client, and
|
||||
// caches the client.
|
||||
inner, ok := as.StateStore.(innerStateStore)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("appservice state store %T does not implement crypto.StateStore", as.StateStore)
|
||||
}
|
||||
store := newLazyStateStore(inner)
|
||||
as.StateStore = store
|
||||
|
||||
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
|
||||
store.client = client
|
||||
// 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)
|
||||
})
|
||||
|
||||
// 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) {
|
||||
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,174 +2,203 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/util"
|
||||
|
||||
"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 // Bot's full Matrix user ID, e.g. @twinbee:parodia.dev
|
||||
UserID string
|
||||
Password string
|
||||
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 authenticated against
|
||||
// Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with
|
||||
// E2EE via the cryptohelper.
|
||||
// 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
|
||||
//
|
||||
// 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.
|
||||
// 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
|
||||
func NewClient(cfg Config) (*mautrix.Client, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
if cfg.UserID == "" {
|
||||
return nil, fmt.Errorf("BOT_USER_ID is required")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
devicePath := filepath.Join(cfg.DataDir, "device.json")
|
||||
|
||||
// ---- MAS OAuth device-grant authentication ----
|
||||
auth := newMASAuth(cfg.DataDir)
|
||||
auth.load()
|
||||
if err := auth.discover(cfg.Homeserver); err != nil {
|
||||
return nil, fmt.Errorf("MAS discovery: %w", err)
|
||||
}
|
||||
|
||||
if auth.refreshToken != "" {
|
||||
// Returning start: refresh the stored token.
|
||||
if err := auth.refresh(); err != nil {
|
||||
return nil, fmt.Errorf("MAS token refresh failed (delete %s to re-authorize): %w",
|
||||
filepath.Join(cfg.DataDir, "mas_auth.json"), err)
|
||||
}
|
||||
slog.Info("MAS auth: refreshed access token from stored session", "device_id", auth.deviceID)
|
||||
} else {
|
||||
// First run: register a client and run the interactive device grant.
|
||||
if err := auth.ensureClient(cfg.DisplayName); err != nil {
|
||||
return nil, fmt.Errorf("MAS client registration: %w", err)
|
||||
}
|
||||
if err := auth.deviceFlow(ctx); err != nil {
|
||||
return nil, fmt.Errorf("MAS device authorization: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
userID := id.UserID(cfg.UserID)
|
||||
client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token())
|
||||
// Try to load existing device credentials
|
||||
device, err := loadDevice(devicePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client: %w", err)
|
||||
slog.Info("no existing device found, will login fresh")
|
||||
}
|
||||
client.DeviceID = id.DeviceID(auth.deviceID)
|
||||
|
||||
// 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 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)
|
||||
var client *mautrix.Client
|
||||
|
||||
// ---- 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.
|
||||
if device != nil {
|
||||
// Validate existing token
|
||||
valid, _ := util.IsTokenValid(cfg.Homeserver, device.AccessToken)
|
||||
if valid {
|
||||
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
|
||||
userID := id.UserID(device.UserID)
|
||||
client, err = mautrix.NewClient(cfg.Homeserver, userID, device.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client with existing token: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(device.DeviceID)
|
||||
} else {
|
||||
slog.Warn("existing device credentials invalid, logging in again")
|
||||
device = nil
|
||||
}
|
||||
}
|
||||
|
||||
if device == nil {
|
||||
// Fresh login
|
||||
loginResp, err := util.LoginWithPassword(cfg.Homeserver, cfg.UserID, cfg.Password, cfg.DisplayName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
userID := id.UserID(loginResp.UserID)
|
||||
client, err = mautrix.NewClient(cfg.Homeserver, userID, loginResp.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(loginResp.DeviceID)
|
||||
|
||||
// Save device info
|
||||
device = &DeviceInfo{
|
||||
AccessToken: loginResp.AccessToken,
|
||||
DeviceID: loginResp.DeviceID,
|
||||
UserID: loginResp.UserID,
|
||||
}
|
||||
if err := saveDevice(devicePath, device); err != nil {
|
||||
slog.Warn("failed to save device info", "err", err)
|
||||
}
|
||||
|
||||
slog.Info("logged in successfully",
|
||||
"user_id", loginResp.UserID,
|
||||
"device_id", loginResp.DeviceID,
|
||||
)
|
||||
}
|
||||
|
||||
// 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).
|
||||
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)
|
||||
}
|
||||
// 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 {
|
||||
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||
}
|
||||
|
||||
// Attach crypto helper to client
|
||||
client.Crypto = ch
|
||||
|
||||
// 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.
|
||||
// Bootstrap cross-signing: generate keys, sign own device, sign master key.
|
||||
// This makes the bot's device show as "verified" to other users.
|
||||
mach := ch.Machine()
|
||||
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
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")
|
||||
}
|
||||
|
||||
slog.Info("E2EE initialized",
|
||||
"user_id", client.UserID,
|
||||
"device_id", client.DeviceID,
|
||||
"crypto_store", "sqlite-persistent",
|
||||
"auth", "mas-oauth-device-grant",
|
||||
)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// refreshLoop keeps the client's access token fresh for the life of the process.
|
||||
func refreshLoop(ctx context.Context, auth *masAuth, client *mautrix.Client) {
|
||||
for {
|
||||
wait := time.Until(auth.expiry()) - 60*time.Second
|
||||
if wait < 10*time.Second {
|
||||
wait = 10 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
if err := auth.refresh(); err != nil {
|
||||
slog.Error("MAS token refresh failed; retrying in 30s", "err", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
client.AccessToken = auth.token()
|
||||
slog.Debug("MAS token refreshed", "expires_at", auth.expiry().Format(time.RFC3339))
|
||||
func loadDevice(path string) (*DeviceInfo, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info DeviceInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func saveDevice(path string, info *DeviceInfo) error {
|
||||
data, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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,8 +2,6 @@ package bot
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
@@ -11,24 +9,13 @@ import (
|
||||
|
||||
// Registry manages plugin registration and event dispatch.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
plugins []plugin.Plugin
|
||||
ignoredBots map[string]struct{}
|
||||
mu sync.RWMutex
|
||||
plugins []plugin.Plugin
|
||||
}
|
||||
|
||||
// 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.
|
||||
// NewRegistry creates an empty plugin registry.
|
||||
func NewRegistry() *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}
|
||||
return &Registry{}
|
||||
}
|
||||
|
||||
// Register adds a plugin to the registry.
|
||||
@@ -55,9 +42,6 @@ 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 {
|
||||
@@ -87,9 +71,6 @@ 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 {
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
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])
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/crypto"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// lazyStateStore resolves room state from the homeserver on first use instead of
|
||||
// waiting for it to arrive over a sync.
|
||||
//
|
||||
// The appservice has no /sync, so the state store only ever learns a room is
|
||||
// encrypted from an m.room.encryption event pushed in a transaction — which for
|
||||
// an already-configured room never happens again. Anything the bot says on its
|
||||
// own initiative (ambient DMs, expedition beats, briefings) therefore reached
|
||||
// SendMessageEvent with IsEncrypted=false and went out as plaintext into an
|
||||
// encrypted room. The state store being in-memory made every restart a fresh
|
||||
// chance to leak.
|
||||
//
|
||||
// Both reads the send path depends on are resolved here on the first miss and
|
||||
// cached: whether the room is encrypted, and who to share the group session with
|
||||
// (an empty member list encrypts to nobody, which is its own outage). Neither
|
||||
// read is allowed to fail open — a lookup that cannot be resolved returns an
|
||||
// error, which aborts the send. A failed message is recoverable; a plaintext one
|
||||
// that has already landed in an encrypted room is not.
|
||||
type lazyStateStore struct {
|
||||
// Embeds both interfaces the store is consumed through: the appservice
|
||||
// dispatches state updates through the first, and the crypto machine type-
|
||||
// asserts the client's store to the second (cryptohelper.NewCryptoHelper
|
||||
// rejects a store that fails the assertion). Embedding only the appservice
|
||||
// interface silently drops crypto's extra methods from the concrete type.
|
||||
innerStateStore
|
||||
|
||||
client *mautrix.Client
|
||||
|
||||
mu sync.Mutex
|
||||
locks map[id.RoomID]*sync.Mutex
|
||||
// Rooms whose m.room.encryption state we have asked the server about. The
|
||||
// underlying store cannot distinguish "not encrypted" from "never heard of
|
||||
// it", so the distinction is tracked here.
|
||||
encryptionResolved map[id.RoomID]bool
|
||||
}
|
||||
|
||||
// innerStateStore is the full method set the wrapped store must provide.
|
||||
type innerStateStore interface {
|
||||
appservice.StateStore
|
||||
crypto.StateStore
|
||||
}
|
||||
|
||||
// The wrapper is consumed through both, and losing either one is a startup
|
||||
// failure rather than a build failure without these.
|
||||
var (
|
||||
_ appservice.StateStore = (*lazyStateStore)(nil)
|
||||
_ crypto.StateStore = (*lazyStateStore)(nil)
|
||||
)
|
||||
|
||||
func newLazyStateStore(inner innerStateStore) *lazyStateStore {
|
||||
return &lazyStateStore{
|
||||
innerStateStore: inner,
|
||||
locks: make(map[id.RoomID]*sync.Mutex),
|
||||
encryptionResolved: make(map[id.RoomID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// lockRoom serialises resolution per room so a burst of sends into a cold room
|
||||
// makes one request rather than one per message.
|
||||
func (s *lazyStateStore) lockRoom(roomID id.RoomID) func() {
|
||||
s.mu.Lock()
|
||||
lock, ok := s.locks[roomID]
|
||||
if !ok {
|
||||
lock = &sync.Mutex{}
|
||||
s.locks[roomID] = lock
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
lock.Lock()
|
||||
return lock.Unlock
|
||||
}
|
||||
|
||||
func (s *lazyStateStore) isEncryptionResolved(roomID id.RoomID) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.encryptionResolved[roomID]
|
||||
}
|
||||
|
||||
func (s *lazyStateStore) markEncryptionResolved(roomID id.RoomID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.encryptionResolved[roomID] = true
|
||||
}
|
||||
|
||||
// IsEncrypted answers from the underlying store once the room's encryption state
|
||||
// has been resolved, fetching it from the server the first time.
|
||||
func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) {
|
||||
if !s.isEncryptionResolved(roomID) {
|
||||
unlock := s.lockRoom(roomID)
|
||||
defer unlock()
|
||||
|
||||
if !s.isEncryptionResolved(roomID) {
|
||||
var enc event.EncryptionEventContent
|
||||
err := s.client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc)
|
||||
switch {
|
||||
case err == nil:
|
||||
if enc.Algorithm != "" {
|
||||
if err := s.innerStateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil {
|
||||
return false, fmt.Errorf("cache encryption state for %s: %w", roomID, err)
|
||||
}
|
||||
}
|
||||
case errors.Is(err, mautrix.MNotFound):
|
||||
// No m.room.encryption at all: the room really is unencrypted.
|
||||
default:
|
||||
// Unresolved. Refuse rather than guess "unencrypted" and leak.
|
||||
return false, fmt.Errorf("resolve encryption state for %s: %w", roomID, err)
|
||||
}
|
||||
s.markEncryptionResolved(roomID)
|
||||
}
|
||||
}
|
||||
return s.innerStateStore.IsEncrypted(ctx, roomID)
|
||||
}
|
||||
|
||||
// GetEncryptionEvent is how the crypto machine reads a room's megolm settings
|
||||
// (session rotation period, etc). It needs the same resolution as IsEncrypted,
|
||||
// since an unresolved room would otherwise report no encryption event.
|
||||
func (s *lazyStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) {
|
||||
if _, err := s.IsEncrypted(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.innerStateStore.GetEncryptionEvent(ctx, roomID)
|
||||
}
|
||||
|
||||
// GetRoomJoinedOrInvitedMembers backs megolm session sharing. Without a sync
|
||||
// there is no member backfill, so an unfetched room would report zero members
|
||||
// and the bot would encrypt to an audience of nobody.
|
||||
func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) {
|
||||
fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
|
||||
}
|
||||
if !fetched {
|
||||
unlock := s.lockRoom(roomID)
|
||||
defer unlock()
|
||||
|
||||
if fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID); err != nil {
|
||||
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
|
||||
} else if !fetched {
|
||||
members, err := s.client.Members(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch members of %s: %w", roomID, err)
|
||||
}
|
||||
// No membership filter, so this also marks the room as fetched.
|
||||
if err := s.innerStateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil {
|
||||
return nil, fmt.Errorf("cache members of %s: %w", roomID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.innerStateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// fakeHS serves the two endpoints the lazy store resolves against, counting hits
|
||||
// so we can assert it caches instead of re-asking on every send.
|
||||
type fakeHS struct {
|
||||
encryptionStatus int
|
||||
encryptionBody string
|
||||
membersBody string
|
||||
|
||||
encryptionHits atomic.Int32
|
||||
memberHits atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fakeHS) start(t *testing.T) *mautrix.Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/state/m.room.encryption"):
|
||||
f.encryptionHits.Add(1)
|
||||
w.WriteHeader(f.encryptionStatus)
|
||||
_, _ = w.Write([]byte(f.encryptionBody))
|
||||
case strings.HasSuffix(r.URL.Path, "/members"):
|
||||
f.memberHits.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(f.membersBody))
|
||||
default:
|
||||
t.Errorf("unexpected request to %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client, err := mautrix.NewClient(srv.URL, "@bot:example.org", "token")
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T, hs *fakeHS) *lazyStateStore {
|
||||
t.Helper()
|
||||
store := newLazyStateStore(mautrix.NewMemoryStateStore().(innerStateStore))
|
||||
store.client = hs.start(t)
|
||||
return store
|
||||
}
|
||||
|
||||
// The store is handed to the crypto helper, which type-asserts it and refuses to
|
||||
// start if it comes up short. That assertion is the whole bot's startup path, so
|
||||
// pin it here rather than discovering it as a crash loop in prod.
|
||||
func TestStoreSatisfiesCryptoHelper(t *testing.T) {
|
||||
store := newTestStore(t, &fakeHS{})
|
||||
|
||||
client := store.client
|
||||
client.StateStore = store
|
||||
|
||||
dir := t.TempDir()
|
||||
if _, err := cryptohelper.NewCryptoHelper(client, []byte("test"), filepath.Join(dir, "crypto.db")); err != nil {
|
||||
t.Fatalf("crypto helper rejected the state store, so the bot would not start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const roomID = id.RoomID("!room:example.org")
|
||||
|
||||
// The regression: a room configured as encrypted before the process started. No
|
||||
// transaction ever re-announces it, so the store must go ask.
|
||||
func TestIsEncryptedResolvesFromServer(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusOK,
|
||||
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsEncrypted: %v", err)
|
||||
}
|
||||
if !enc {
|
||||
t.Fatal("room is encrypted on the server but IsEncrypted said false — this is the plaintext leak")
|
||||
}
|
||||
}
|
||||
|
||||
// A room with no m.room.encryption really is plaintext, and must not re-fetch.
|
||||
func TestIsEncryptedCachesUnencrypted(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusNotFound,
|
||||
encryptionBody: `{"errcode":"M_NOT_FOUND","error":"Event not found."}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsEncrypted: %v", err)
|
||||
}
|
||||
if enc {
|
||||
t.Fatal("unencrypted room reported as encrypted")
|
||||
}
|
||||
}
|
||||
if got := hs.encryptionHits.Load(); got != 1 {
|
||||
t.Errorf("expected the M_NOT_FOUND to be cached, got %d fetches", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The important half: an unresolvable room must fail the send, not quietly
|
||||
// answer "not encrypted" and let the message out in the clear.
|
||||
func TestIsEncryptedFailsClosed(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusInternalServerError,
|
||||
encryptionBody: `{"errcode":"M_UNKNOWN","error":"oops"}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
if _, err := store.IsEncrypted(context.Background(), roomID); err == nil {
|
||||
t.Fatal("a failed lookup returned no error; the send would proceed in plaintext")
|
||||
}
|
||||
// The failure must not poison the cache: a later send has to retry.
|
||||
hs.encryptionStatus = http.StatusOK
|
||||
hs.encryptionBody = `{"algorithm":"m.megolm.v1.aes-sha2"}`
|
||||
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsEncrypted after recovery: %v", err)
|
||||
}
|
||||
if !enc {
|
||||
t.Fatal("store did not retry after a transient failure")
|
||||
}
|
||||
}
|
||||
|
||||
// A burst of proactive sends into a cold room should resolve it once.
|
||||
func TestIsEncryptedResolvesOncePerRoom(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusOK,
|
||||
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := store.IsEncrypted(context.Background(), roomID); err != nil {
|
||||
t.Errorf("IsEncrypted: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := hs.encryptionHits.Load(); got != 1 {
|
||||
t.Errorf("expected 1 state fetch for 20 concurrent sends, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypting to an empty member list would share the group session with nobody,
|
||||
// so members must be resolved too.
|
||||
func TestMembersResolveFromServer(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusOK,
|
||||
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||
membersBody: `{"chunk":[
|
||||
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@alice:example.org","sender":"@alice:example.org","content":{"membership":"join"}},
|
||||
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@bot:example.org","sender":"@bot:example.org","content":{"membership":"join"}}
|
||||
]}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
members, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRoomJoinedOrInvitedMembers: %v", err)
|
||||
}
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("expected 2 members, got %d (%v) — the group session would be shared to nobody", len(members), members)
|
||||
}
|
||||
|
||||
if _, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID); err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if got := hs.memberHits.Load(); got != 1 {
|
||||
t.Errorf("expected members to be cached after one fetch, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -306,18 +306,6 @@ func runMigrations(d *sql.DB) error {
|
||||
`ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
|
||||
// Bored adventurers (gogobee_boredom_plan.md §1). The idle clock for
|
||||
// autonomous expedition starts. Deliberately NOT last_active_at: that
|
||||
// one is auto-bumped by saveAdvCharacter, so the autopilot's own writes
|
||||
// would refresh a bored character's idle clock and it would never
|
||||
// qualify again. Written only by markPlayerAction, from a real player
|
||||
// action against Adventure (any interface, not just Matrix chatter).
|
||||
`ALTER TABLE player_meta ADD COLUMN last_player_action_at DATETIME`,
|
||||
`ALTER TABLE player_meta ADD COLUMN last_boredom_at DATETIME`,
|
||||
// Marks an expedition the adventurer started by itself. The idle reaper
|
||||
// reads it: an expedition nobody asked for must not shield its player
|
||||
// from the shame DM or hold their streak (gogobee_boredom_plan.md §6).
|
||||
`ALTER TABLE dnd_expedition ADD COLUMN boredom INTEGER NOT NULL DEFAULT 0`,
|
||||
// Adv 2.0 Phase G1 — branching zone graph run-state columns.
|
||||
// current_node / visited_nodes / node_choices live alongside the
|
||||
// legacy current_room / room_seq_json during the migration; the
|
||||
@@ -348,120 +336,6 @@ 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`,
|
||||
// N4/E1 housing vault (gogobee_engagement_plan.md §E1). A Tier-4 Estate
|
||||
// unlocks a 10-slot vault that shelters items from sale/use. Rather than a
|
||||
// parallel table, a stowed item keeps its identity (id, temper, everything)
|
||||
// and flips in_vault=1; loadAdvInventory filters it back out, so a vaulted
|
||||
// item drops out of sell/craft/combat readers as one flag change. DEFAULT 0
|
||||
// = "in play", correct for every pre-existing row, so no bootstrap backfill.
|
||||
`ALTER TABLE adventure_inventory ADD COLUMN in_vault INTEGER NOT NULL DEFAULT 0`,
|
||||
// N4/E1 second pet slot (gogobee_engagement_plan.md §E1). A Tier-4 Estate
|
||||
// unlocks a second companion. It lives in a parallel pet2_* column set
|
||||
// rather than a rows table so the single-pet path (and its combat golden)
|
||||
// is untouched: absent pet2_type == "no second pet", DEFAULT '' is correct
|
||||
// for every pre-existing row, so no bootstrap backfill. Pet 2 carries only
|
||||
// what it needs — identity, level, barding — and deliberately skips the
|
||||
// supply-shop unlock (a pet-1 mechanic) and morning-defense flag.
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_type TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_level INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_armor_tier INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_flags_json TEXT NOT NULL DEFAULT '{}'`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_level_10_date TEXT NOT NULL DEFAULT ''`,
|
||||
// N5/D1 the Hollow King campaign (gogobee_engagement_plan.md §D1). Found
|
||||
// journal pages are a bitmask (bit i == page i+1 discovered), granted one at
|
||||
// a time from elite kills and secret rooms. A single INTEGER rather than a
|
||||
// rows table: pages are static content, only found/not-found is per-player,
|
||||
// and grants are an atomic bitwise-OR so a page can't be lost to a stale
|
||||
// character save. DEFAULT 0 == "no pages found", correct for every
|
||||
// pre-existing row, so no bootstrap backfill.
|
||||
`ALTER TABLE player_meta ADD COLUMN journal_pages INTEGER NOT NULL DEFAULT 0`,
|
||||
// N5/D1c the Hollow King finale (gogobee_engagement_plan.md §D1). Set once,
|
||||
// the first time a player closes the account — the reward (unique title +
|
||||
// one Legendary) drops only on that first clear; later rematches are
|
||||
// flavour-only. Written by a dedicated atomic UPDATE, never the bulk
|
||||
// character save, so a monotonic false→true flag can't be clobbered.
|
||||
// DEFAULT 0 correct for every pre-existing row, so no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`,
|
||||
// N7/B2 Renown (gogobee_engagement_plan.md §B2). At the L20 cap, overflow
|
||||
// XP that grantDnDXP used to drop instead accumulates here as cumulative
|
||||
// prestige XP. renown_xp is monotonic and written by an atomic += (the
|
||||
// journal_pages pattern), never the bulk character save; renown_level is
|
||||
// DERIVED from it (renown_xp / renownXPPerLevel) rather than stored, so
|
||||
// there is no read-modify-write race and no second field to disagree.
|
||||
// DEFAULT 0 == "no renown", correct for every pre-existing row, no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
|
||||
// The hireable companion (pete_adventure_news_plan.md, "Pete as a
|
||||
// character"). Pete's roster seat records the class and level he was
|
||||
// hired at, so a party that levels mid-expedition doesn't silently
|
||||
// re-roll its hireling into a different class three rooms in. Both are
|
||||
// empty/0 on every player row and are only ever read for the one seat
|
||||
// where isCompanionSeat holds — a player's row never consults them.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_class TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_level INTEGER NOT NULL DEFAULT 0`,
|
||||
|
||||
// The companion's spell-slot ledger, "used" per slot level, as a compact
|
||||
// CSV of six ints (index 0 unused; cantrips cost nothing).
|
||||
//
|
||||
// It has to live on the *expedition*, next to the class and level it is a
|
||||
// pool for. A human caster's slots are dnd_spell_slots rows that persist
|
||||
// across every fight of the run and only come back at camp, so rationing a
|
||||
// pool across a 30-room day IS the caster's game. The first cut of this
|
||||
// parked the companion's ledger on his combat seat instead — and a seat is
|
||||
// per-session, so he walked into every single fight with a full pool. The
|
||||
// sim caught it: a level-penalized, gearless hireling out-cleared a human
|
||||
// cleric of the leader's own level by 15pp.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_slots_used TEXT NOT NULL DEFAULT ''`,
|
||||
|
||||
// The companion's body, carried across the run. -1 means "unset" — he is
|
||||
// at full, which is what a fresh hire is.
|
||||
//
|
||||
// He used to re-seat at full max HP for *every* fight, because he has no
|
||||
// dnd_character row for his HP to persist onto and the close-out skipped
|
||||
// him ("he arrives fresh next time"). That is an infinite body: a player
|
||||
// bleeds across a 30-room run and only heals at camp, while the hireling
|
||||
// soaked half the incoming damage and reset. Measured, it is most of why a
|
||||
// gearless, level-penalized hireling out-cleared a human cleric of the
|
||||
// leader's own level — his party fled 5 runs out of 640 where the human
|
||||
// party fled 56.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`,
|
||||
// Mischief M2 — whoever paid to bump a contract up a tier. Named alongside
|
||||
// the buyer when the target survives (the unseal), so piling on carries the
|
||||
// same exposure the original purchase does.
|
||||
`ALTER TABLE mischief_contracts ADD COLUMN escalated_by TEXT`,
|
||||
// Pete games — a caller-supplied idempotency key for money moves that
|
||||
// arrive over a retrying wire rather than a Matrix message. A Matrix
|
||||
// message arrives once; a poll loop whose ack is lost on the wire will
|
||||
// retry, and without this the player pays twice. See euro.DebitIdem.
|
||||
`ALTER TABLE euro_transactions ADD COLUMN external_id TEXT`,
|
||||
// Pete games — the outbound queue carries more than adventure facts now.
|
||||
// An escrow verdict goes to a different Pete endpoint, but it wants the
|
||||
// same durability, backoff and parking, so it rides the same queue and the
|
||||
// row says where it's going. Existing rows are all facts, hence the default.
|
||||
`ALTER TABLE pete_emit_queue ADD COLUMN path TEXT NOT NULL DEFAULT '/api/ingest/adventure'`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -478,18 +352,6 @@ func runMigrations(d *sql.DB) error {
|
||||
return fmt.Errorf("migration %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes over columns the column migrations above just added. These can't
|
||||
// live in the schema block, which runs before those columns exist.
|
||||
indexMigrations := []string{
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_euro_tx_external
|
||||
ON euro_transactions(external_id) WHERE external_id IS NOT NULL`,
|
||||
}
|
||||
for _, stmt := range indexMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("index migration %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -689,15 +551,6 @@ func RunMaintenance() {
|
||||
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Pete adventure-news queue — reap delivered rows (kept only for
|
||||
// idempotency); undelivered rows stay so the sender can retry/park them.
|
||||
{"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}},
|
||||
// ...and reap permanently-parked rows: the sender exhausts its retries
|
||||
// within a few hours, so anything still unsent after 30 days is dead
|
||||
// weight the drain query already skips — drop it so a durable outage
|
||||
// can't accrete rows forever.
|
||||
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Rate limits — purge entries older than today
|
||||
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
||||
|
||||
@@ -1029,46 +882,6 @@ CREATE TABLE IF NOT EXISTS shade_optout (
|
||||
user_id TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
-- Pete adventure-news seam: durable outbound queue of game-event facts sent to
|
||||
-- the Pete news bot. Emit enqueues (INSERT OR IGNORE on guid = idempotency); a
|
||||
-- background sender drains rows where sent_at IS NULL. Keyed on the fact guid so
|
||||
-- retries and duplicate emits collapse to one row.
|
||||
CREATE TABLE IF NOT EXISTS pete_emit_queue (
|
||||
guid TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||
sent_at INTEGER
|
||||
);
|
||||
|
||||
-- Players who opted out of being named in Pete's adventure news. Enforced at
|
||||
-- emit time (anonymize, never delete). Mirrors shade_optout.
|
||||
CREATE TABLE IF NOT EXISTS news_optout (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
opted_out_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Realm-first ledger for adventure news: the first (kind,target) occurrence
|
||||
-- across all players. INSERT OR IGNORE returns rows-affected>0 exactly once, so
|
||||
-- the first clear of a zone/boss fires as a PRIORITY realm-first and everyone
|
||||
-- after as a BULLETIN personal clear. Seeded by the cold-start backfill so a
|
||||
-- fresh deploy doesn't re-flag historically-cleared content as first-ever.
|
||||
CREATE TABLE IF NOT EXISTS news_realm_firsts (
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
first_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (kind, target)
|
||||
);
|
||||
|
||||
-- Durable runtime config for adventure news (e.g. the emission kill-switch).
|
||||
-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after
|
||||
-- 7 days, which would silently revert an operator's !news off back to on.
|
||||
CREATE TABLE IF NOT EXISTS news_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Birthdays
|
||||
CREATE TABLE IF NOT EXISTS birthdays (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
@@ -1542,21 +1355,6 @@ CREATE TABLE IF NOT EXISTS adventure_activity_log (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_log_user ON adventure_activity_log(user_id, logged_at);
|
||||
|
||||
-- N4/E2 item gifting. One row per gift; the sender's row count for the current
|
||||
-- UTC day enforces the daily cap (twink-funnel guard), and the full log is an
|
||||
-- audit trail. gift_day is the UTC date string the cap counts against, kept
|
||||
-- alongside given_at so the count is a plain equality match, not a range scan.
|
||||
CREATE TABLE IF NOT EXISTS adventure_gift_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
value INTEGER NOT NULL DEFAULT 0,
|
||||
gift_day TEXT NOT NULL,
|
||||
given_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_gift_sender_day ON adventure_gift_log(sender, gift_day);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adventure_treasures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1702,21 +1500,6 @@ 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,
|
||||
@@ -1740,65 +1523,12 @@ CREATE TABLE IF NOT EXISTS adventure_rival_challenges (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at);
|
||||
|
||||
-- Duels (N6/C2): player-initiated, staked, no-death combat bouts. Both stakes
|
||||
-- are escrowed while the row lives; expiry/decline refunds the challenger. W/L
|
||||
-- history reuses adventure_rival_records. No bootstrap — absent row == no duel.
|
||||
CREATE TABLE IF NOT EXISTS adventure_duel_challenges (
|
||||
challenge_id TEXT PRIMARY KEY,
|
||||
challenger_id TEXT NOT NULL,
|
||||
challenged_id TEXT NOT NULL,
|
||||
stake INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_duel_challenges_user ON adventure_duel_challenges(challenged_id, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_duel_challenges_expiry ON adventure_duel_challenges(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community_pot (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
balance INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Mischief Makers: a paid monster hit on a player who is out on an expedition.
|
||||
-- The buyer's euros are debited at placement (paid); fee is the payout basis the
|
||||
-- target's survival purse is a percentage of — the sign surcharge is a pure sink
|
||||
-- and never inflates the purse, which is what keeps a payout strictly below what
|
||||
-- the buyer spent (collusion loses to !baltransfer, which is free).
|
||||
--
|
||||
-- Lifecycle: open → delivering → delivered (outcome survived|downed) | fizzled.
|
||||
-- Every transition is a conditional UPDATE, so a double-fire of the ticker or a
|
||||
-- restart mid-delivery can never pay twice. No bootstrap — absent row == no
|
||||
-- contract in flight.
|
||||
CREATE TABLE IF NOT EXISTS mischief_contracts (
|
||||
contract_id TEXT PRIMARY KEY,
|
||||
buyer_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
tier TEXT NOT NULL,
|
||||
fee INTEGER NOT NULL,
|
||||
paid INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
signed INTEGER NOT NULL DEFAULT 0,
|
||||
escalation_count INTEGER NOT NULL DEFAULT 0,
|
||||
escalated_by TEXT,
|
||||
blessing_count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'matrix',
|
||||
outcome TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
window_ends_at DATETIME NOT NULL,
|
||||
resolved_at DATETIME
|
||||
);
|
||||
-- One live contract per target, enforced by the database rather than by a
|
||||
-- read-then-write check. The placement path holds only the BUYER's lock, so two
|
||||
-- different buyers racing to hit the same person would both pass an in-code
|
||||
-- "is one already live?" test. This partial unique index is what actually stops
|
||||
-- the second insert; the loser is refunded.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mischief_one_live_per_target
|
||||
ON mischief_contracts(target_id) WHERE status IN ('open', 'delivering');
|
||||
CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mischief_buyer ON mischief_contracts(buyer_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_at);
|
||||
|
||||
-- Babysitting Service
|
||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -2271,24 +2001,6 @@ 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
|
||||
@@ -2319,152 +2031,12 @@ 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,
|
||||
-- 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
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_combat_session_active
|
||||
ON combat_session(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_combat_session_expiry
|
||||
ON combat_session(status, expires_at);
|
||||
|
||||
-- ── 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);
|
||||
|
||||
-- ── N6/D3 — the Shadow: a simulated rival adventurer ──────────────────────
|
||||
-- One row per player, born lazily the first midnight after their character
|
||||
-- exists. The Shadow "runs" the same zone progression on a simulated schedule
|
||||
-- (~1.3x the player's own clear pace), advanced once per UTC day by the
|
||||
-- midnight ticker. Pure theatre: no combat, no punishment — only race
|
||||
-- pressure surfaced in the morning briefing and a payoff at each zone clear.
|
||||
--
|
||||
-- Absent == the player has no Shadow yet, which is true of every row that
|
||||
-- existed before N6, so there is nothing to backfill. The ticker mints the
|
||||
-- row on first advance. This table is deliberately NOT part of the
|
||||
-- player_meta save fan-out: the ticker owns it, so a character save can never
|
||||
-- clobber the Shadow's advance (the same isolation journal_pages earns by
|
||||
-- being grant-only, made structural here).
|
||||
--
|
||||
-- name: the rival's proper name, seeded deterministically from the
|
||||
-- player's display name at birth.
|
||||
-- progress: cumulative zone-units the Shadow has run (fractional, so a
|
||||
-- slow player still sees it creep between clears).
|
||||
-- zones_cleared: floor(progress) at the last advance — the committed count,
|
||||
-- stored so the next advance can tell which zones were newly
|
||||
-- finished.
|
||||
-- pending_mask: zones (by progression index bit) the Shadow cleared before
|
||||
-- the player did — a journal page waits in each until the
|
||||
-- player clears that zone's boss.
|
||||
-- crowed_mask: zones the player beat the Shadow to and has already been
|
||||
-- crowed a bonus for — set-once, so re-running a zone the
|
||||
-- Shadow hasn't reached can't farm the crow XP.
|
||||
-- day_counter: the Shadow's own day count, for flavour variety.
|
||||
-- last_advanced: UTC date of the last advance; the per-day idempotency guard.
|
||||
CREATE TABLE IF NOT EXISTS adventure_shadow (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
progress REAL NOT NULL DEFAULT 0,
|
||||
zones_cleared INTEGER NOT NULL DEFAULT 0,
|
||||
pending_mask INTEGER NOT NULL DEFAULT 0,
|
||||
crowed_mask INTEGER NOT NULL DEFAULT 0,
|
||||
day_counter INTEGER NOT NULL DEFAULT 0,
|
||||
last_advanced TEXT NOT NULL DEFAULT '',
|
||||
born_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- World boss (N6/C3) — the monthly communal "Siege". One event at a time is
|
||||
-- live (status='active'); the ticker resolves it at hp_current<=0 (defeated) or
|
||||
-- after ends_at (survived). History rows are retained (autoincrement id), so
|
||||
-- world_boss_contrib keys on boss_id. Deliberately its own tables, outside the
|
||||
-- player_meta save fan-out, so a character save can't clobber the shared pool.
|
||||
CREATE TABLE IF NOT EXISTS world_boss (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL DEFAULT 5,
|
||||
hp_max INTEGER NOT NULL,
|
||||
hp_current INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
starts_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ends_at DATETIME NOT NULL,
|
||||
resolved_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS world_boss_contrib (
|
||||
boss_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
fights INTEGER NOT NULL DEFAULT 0,
|
||||
damage INTEGER NOT NULL DEFAULT 0,
|
||||
last_fight_date TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (boss_id, user_id)
|
||||
);
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
|
||||
@@ -25,25 +25,6 @@ var ExpeditionStart = []string{
|
||||
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EXPEDITION START — BOREDOM (the adventurer left without you)
|
||||
//
|
||||
// Fired by the boredom ticker after a long silence. The player is not
|
||||
// reading this live; it's a note left on the table. Deadpan, faintly
|
||||
// reproachful, never cruel — and never pretending the gear got checked,
|
||||
// because it didn't (gogobee_boredom_plan.md §5).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExpeditionBoredomStart = []string{
|
||||
"You didn't come. That's alright — it happens, and I'm not going to make it a thing. But the sword was getting heavy on the wall and I've packed what we had. Which was not much. Noted for the record, not as a complaint.",
|
||||
"I waited. Then I waited past the point where waiting was the sensible option, and somewhere in there the waiting turned into leaving. We're going. Same kit as last time, because last time is when you last touched it.",
|
||||
"Here's the situation: there's a dungeon, there's daylight, and there's nobody telling me not to. I've made a decision. I hope it was the one you'd have made, though I concede I have no way of checking.",
|
||||
"Supplies: the cheapest available. Equipment: whatever was already on the rack. Plan: walk in, see what happens. I'm aware of how that sounds. I'm going anyway.",
|
||||
"The gear hasn't moved since you left it. I checked. I checked twice, actually, in case the first check was wrong, and it wasn't. So we go as we are — which is to say, as we were.",
|
||||
"Restlessness is not a stat I can show you on the sheet, but it accumulates, and it has. Off we go. Lightly provisioned and unimproved, but off.",
|
||||
"I've done the arithmetic on standing still and it doesn't come out well. So: a dungeon, one supply pack, and the same armour that's been good enough up to now. 'Good enough' is doing a lot of work in that sentence.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MORNING BRIEFINGS — Generic
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
// Package peteclient is gogobee's outbound seam to the Pete news bot.
|
||||
//
|
||||
// gogobee is the source of game-event *facts* and owns delivery; Pete owns
|
||||
// voice, authoring, and publishing. This package carries structured facts (not
|
||||
// prose) to Pete's ingest endpoint over the tailnet, bearer-authed.
|
||||
//
|
||||
// Delivery is durable: Emit writes the fact to a SQLite queue and returns
|
||||
// immediately, so a game-loop hook never blocks on the network and a Pete
|
||||
// restart loses nothing. A background sender drains the queue with retry.
|
||||
// Idempotency is on the fact GUID, so retries and duplicate emits are no-ops.
|
||||
package peteclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// Fact is the flat, pre-sanitized payload gogobee POSTs to Pete. Names must be
|
||||
// character names only (never Matrix handles); Actors is the allow-list of the
|
||||
// only names permitted to appear in Pete's rendered output. See
|
||||
// pete_adventure_news_voice.md for the field contract.
|
||||
type Fact struct {
|
||||
GUID string `json:"guid"` // stable idempotency key, e.g. "death:<token>:<ts>"; prefix == event_type
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Opponent string `json:"opponent,omitempty"`
|
||||
Boss string `json:"boss,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Stakes string `json:"stakes,omitempty"`
|
||||
ClassRace string `json:"class_race,omitempty"`
|
||||
Milestone string `json:"milestone,omitempty"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
||||
}
|
||||
|
||||
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
|
||||
// queued), matching the FEATURE_PETE_NEWS master switch that kills emission at
|
||||
// the source.
|
||||
type Config struct {
|
||||
IngestURL string
|
||||
Token string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Client is the transport half. It is a package singleton initialized by Init,
|
||||
// so emit hooks scattered across plugins (and free functions like
|
||||
// markAdventureDead) can call Emit without threading a handle through.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
draining sync.Mutex // one drain at a time; see drain
|
||||
}
|
||||
|
||||
var std *Client
|
||||
|
||||
// factPath is where an adventure fact goes. Every queue row carries its own
|
||||
// destination now, because escrow verdicts ride the same queue to a different
|
||||
// endpoint.
|
||||
const factPath = "/api/ingest/adventure"
|
||||
|
||||
// Tuning for the background sender.
|
||||
const (
|
||||
senderTick = 15 * time.Second
|
||||
senderBatch = 20
|
||||
maxAttempts = 8 // ~ up to a few hours of backoff, then park
|
||||
backoffBase = 30 * time.Second
|
||||
backoffCapSec = 3600
|
||||
sendTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// Init wires the singleton from the environment. Mirrors the per-plugin config
|
||||
// pattern (email_nag.go): PETE_INGEST_URL, PETE_INGEST_TOKEN, FEATURE_PETE_NEWS.
|
||||
func Init() {
|
||||
cfg := Config{
|
||||
IngestURL: strings.TrimRight(os.Getenv("PETE_INGEST_URL"), "/"),
|
||||
Token: os.Getenv("PETE_INGEST_TOKEN"),
|
||||
Enabled: strings.EqualFold(os.Getenv("FEATURE_PETE_NEWS"), "true"),
|
||||
}
|
||||
if cfg.Enabled && (cfg.IngestURL == "" || cfg.Token == "") {
|
||||
slog.Warn("peteclient: FEATURE_PETE_NEWS=true but PETE_INGEST_URL/PETE_INGEST_TOKEN unset — disabling")
|
||||
cfg.Enabled = false
|
||||
}
|
||||
std = &Client{cfg: cfg, http: &http.Client{Timeout: sendTimeout}}
|
||||
if cfg.Enabled {
|
||||
slog.Info("peteclient: adventure news emission enabled", "ingest", cfg.IngestURL)
|
||||
} else {
|
||||
slog.Info("peteclient: adventure news emission disabled (set FEATURE_PETE_NEWS=true)")
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether emission is on. Callers can skip building an
|
||||
// (expensive) fact when it would be dropped anyway.
|
||||
func Enabled() bool { return std != nil && std.cfg.Enabled }
|
||||
|
||||
// Emit durably queues a fact for delivery to Pete. It never blocks on the
|
||||
// network. A no-op (but safe) when the seam is disabled or the GUID was already
|
||||
// queued — idempotency is on the GUID primary key.
|
||||
func Emit(f Fact) {
|
||||
if !Enabled() {
|
||||
return
|
||||
}
|
||||
if f.GUID == "" {
|
||||
slog.Error("peteclient: refusing to queue fact with empty guid", "event_type", f.EventType)
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
|
||||
return
|
||||
}
|
||||
enqueue(f.GUID, factPath, payload)
|
||||
}
|
||||
|
||||
// enqueue puts one payload on the durable queue, addressed to a Pete endpoint.
|
||||
//
|
||||
// OR IGNORE gives GUID-idempotency: a re-emit of the same key is dropped. That
|
||||
// is the whole safety story for money — an escrow verdict is queued under its
|
||||
// escrow guid, so a verdict can never be enqueued twice and can never be
|
||||
// delivered as two different answers.
|
||||
func enqueue(guid, path string, payload []byte) {
|
||||
db.Exec("pete emit enqueue",
|
||||
`INSERT OR IGNORE INTO pete_emit_queue (guid, path, payload, created_at, attempts, next_attempt_at)
|
||||
VALUES (?, ?, ?, unixepoch(), 0, 0)`,
|
||||
guid, path, string(payload))
|
||||
}
|
||||
|
||||
// StartSender launches the background drain loop. It runs until ctx is
|
||||
// canceled. Safe to call when disabled — it simply idles.
|
||||
func StartSender(ctx context.Context) {
|
||||
if std == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(senderTick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if std.cfg.Enabled {
|
||||
std.drain(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Flush drains the queue right now instead of waiting for the next tick.
|
||||
//
|
||||
// The escrow loop needs this. A player who clicked "buy chips" is watching a
|
||||
// spinner, and a verdict that sat in the queue for a 15-second sender tick would
|
||||
// make the whole border feel broken even though nothing is. Durability is not
|
||||
// weakened: the row is written first and only then sent, exactly as the ticker
|
||||
// does it.
|
||||
func Flush(ctx context.Context) {
|
||||
if std == nil || !std.cfg.Enabled {
|
||||
return
|
||||
}
|
||||
std.drain(ctx)
|
||||
}
|
||||
|
||||
// drain sends up to senderBatch due rows, one at a time.
|
||||
//
|
||||
// Serialized: the ticker and Flush can both call this, and two drains racing
|
||||
// would send the same row twice. Every Pete endpoint we push to is idempotent,
|
||||
// so that would be survivable rather than harmful — but it would also mean an
|
||||
// escrow verdict arriving twice as a matter of routine, and "harmless in theory"
|
||||
// is not how the money path should be run.
|
||||
func (c *Client) drain(ctx context.Context) {
|
||||
c.draining.Lock()
|
||||
defer c.draining.Unlock()
|
||||
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT guid, path, payload FROM pete_emit_queue
|
||||
WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch()
|
||||
ORDER BY created_at LIMIT ?`,
|
||||
maxAttempts, senderBatch)
|
||||
if err != nil {
|
||||
slog.Error("peteclient: drain query", "err", err)
|
||||
return
|
||||
}
|
||||
type item struct{ guid, path, payload string }
|
||||
var batch []item
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.guid, &it.path, &it.payload); err != nil {
|
||||
slog.Error("peteclient: drain scan", "err", err)
|
||||
continue
|
||||
}
|
||||
batch = append(batch, it)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, it := range batch {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := c.post(ctx, it.path, []byte(it.payload)); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// Shutdown canceled the in-flight send — Pete didn't reject
|
||||
// anything. Don't burn a durable retry attempt; the row is picked
|
||||
// up on the next boot's drain.
|
||||
return
|
||||
}
|
||||
db.Exec("pete emit retry",
|
||||
`UPDATE pete_emit_queue
|
||||
SET attempts = attempts + 1, next_attempt_at = unixepoch() + ?
|
||||
WHERE guid = ?`,
|
||||
backoffSec(it.guid), it.guid)
|
||||
slog.Warn("peteclient: emit failed, will retry", "guid", it.guid, "err", err)
|
||||
continue
|
||||
}
|
||||
db.Exec("pete emit sent",
|
||||
`UPDATE pete_emit_queue SET sent_at = unixepoch() WHERE guid = ?`, it.guid)
|
||||
}
|
||||
}
|
||||
|
||||
// RosterEntry is one adventurer's currently-true state for Pete's live board.
|
||||
// Unlike a Fact, nothing here is an event — it is what is true right now.
|
||||
type RosterEntry struct {
|
||||
Token string `json:"token"` // stable per-player board token, never a Matrix handle
|
||||
Name string `json:"name"` // character name only
|
||||
Level int `json:"level"`
|
||||
ClassRace string `json:"class_race,omitempty"`
|
||||
Status string `json:"status"` // "expedition" | "idle"
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
IdleHours int `json:"idle_hours,omitempty"`
|
||||
}
|
||||
|
||||
// RosterSnapshot is the complete board. Complete is load-bearing: Pete replaces
|
||||
// its whole board with this, so anyone omitted (opted out, no character) drops
|
||||
// off the public page. A partial snapshot would silently strand people on it.
|
||||
type RosterSnapshot struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Adventurers []RosterEntry `json:"adventurers"`
|
||||
}
|
||||
|
||||
// PushRoster sends the board to Pete, synchronously, and drops it on failure.
|
||||
//
|
||||
// Deliberately NOT on the durable queue that carries Facts. A fact is history —
|
||||
// losing "Josie died" loses it forever, so it retries. A snapshot is a
|
||||
// photograph of the present, and a retried one is a *lie*: by the time it lands,
|
||||
// Josie has moved. The next tick carries the truth anyway, so a failed push is
|
||||
// simply forgotten. That is also what lets Pete's staleness timer work — if we
|
||||
// stay down, nothing arrives, and the board correctly stops claiming to be live.
|
||||
func PushRoster(ctx context.Context, snap RosterSnapshot) error {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/ingest/roster", payload)
|
||||
}
|
||||
|
||||
// post sends one payload to a Pete endpoint with bearer auth. Mirrors the
|
||||
// bearer-POST pattern in email_nag.go:sendCode.
|
||||
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
|
||||
url := c.cfg.IngestURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("pete ingest status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The euro/chip border
|
||||
//
|
||||
// Pete holds chips; we hold the euros. A player buying in or cashing out opens
|
||||
// an escrow row on Pete, and we are the only one who can move the money for it —
|
||||
// Pete has no route into this box's network and is not getting one. So we poll.
|
||||
//
|
||||
// This is the first GET gogobee has ever made to Pete. Everything else in this
|
||||
// package is us pushing facts outward; here we are asking for work.
|
||||
//
|
||||
// The escrow guid is the idempotency key end to end: it names the row on Pete,
|
||||
// it is the external_id on our euro transaction, and it is the queue key of the
|
||||
// verdict we push back. That is what makes every step here safe to retry, which
|
||||
// matters because every step here can be interrupted between moving real money
|
||||
// and saying so.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Escrow is one pending crossing, as Pete describes it. Amounts are whole euros:
|
||||
// chips are 1:1 and there is no sub-unit to lose.
|
||||
type Escrow struct {
|
||||
GUID string `json:"guid"`
|
||||
MatrixUser string `json:"matrix_user"`
|
||||
Kind string `json:"kind"` // "buyin" | "cashout"
|
||||
Amount int64 `json:"amount"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// EscrowVerdict is our answer: did the euros move, and what is the balance now.
|
||||
// A rejected buy-in carries the reason, which Pete shows the player.
|
||||
type EscrowVerdict struct {
|
||||
GUID string `json:"guid"`
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BalanceAfter float64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
const escrowVerdictPath = "/api/games/escrow/settled"
|
||||
|
||||
// PendingEscrow asks Pete for crossings waiting on us. Includes rows we claimed
|
||||
// but never answered — if we died holding one, the player's money is stranded
|
||||
// until we pick it up again.
|
||||
func PendingEscrow(ctx context.Context) ([]Escrow, error) {
|
||||
if !Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
var out []Escrow
|
||||
if err := std.getJSON(ctx, "/api/games/escrow/pending", &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClaimEscrow tells Pete we are taking a row, and returns the row as Pete now
|
||||
// holds it. Move the money against *this*, not against the copy from the poll:
|
||||
// the claim is the moment the amount and the player are fixed.
|
||||
//
|
||||
// A row Pete has already decided comes back in a terminal state rather than
|
||||
// "claimed". That is not an error — it means the work is done, and it is exactly
|
||||
// what stops a settled cash-out from being paid a second time.
|
||||
func ClaimEscrow(ctx context.Context, guid string) (Escrow, error) {
|
||||
var e Escrow
|
||||
payload, err := json.Marshal(map[string]string{"guid": guid})
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
if err := std.postJSON(ctx, "/api/games/escrow/claim", payload, &e); err != nil {
|
||||
return e, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// EmitEscrowVerdict durably queues our answer and returns immediately. Keyed on
|
||||
// the escrow guid, so a verdict is enqueued once and only once, and the sender's
|
||||
// retry/backoff/parking machinery carries it the rest of the way.
|
||||
//
|
||||
// The caller should Flush after this: a player is watching a spinner.
|
||||
func EmitEscrowVerdict(v EscrowVerdict) {
|
||||
if !Enabled() {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
slog.Error("peteclient: marshal escrow verdict", "guid", v.GUID, "err", err)
|
||||
return
|
||||
}
|
||||
// Namespaced so an escrow guid can never collide with a fact guid in the
|
||||
// queue's primary key. Fact guids are "<event_type>:<token>:<ts>"; escrow
|
||||
// guids are random. A collision would be a lost verdict, so don't rely on
|
||||
// luck for it.
|
||||
enqueue("escrow:"+v.GUID, escrowVerdictPath, payload)
|
||||
}
|
||||
|
||||
// getJSON does a bearer-authed GET and decodes the body.
|
||||
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
// postJSON does a bearer-authed POST and decodes the body. Distinct from post,
|
||||
// which is the fire-and-forget path the queue uses and ignores the response.
|
||||
func (c *Client) postJSON(ctx context.Context, path string, payload []byte, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.IngestURL+path, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("pete %s status %d: %s", req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("pete %s: decode: %w", req.URL.Path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backoffSec computes the retry delay for a row. It re-reads the current attempt
|
||||
// count so the delay grows geometrically without needing it passed in.
|
||||
func backoffSec(guid string) int {
|
||||
var attempts int
|
||||
_ = db.Get().QueryRow(`SELECT attempts FROM pete_emit_queue WHERE guid = ?`, guid).Scan(&attempts)
|
||||
// attempts is the count *before* this failure's increment; delay off it.
|
||||
delay := int(backoffBase.Seconds()) << attempts
|
||||
if delay > backoffCapSec {
|
||||
delay = backoffCapSec
|
||||
}
|
||||
return delay
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package peteclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// TestEmitDrainRoundTrip proves the durable path: Emit queues a fact, the sender
|
||||
// POSTs it to Pete with bearer auth, and the row is marked sent (so it won't
|
||||
// re-send), while a duplicate GUID collapses to one delivery.
|
||||
func TestEmitDrainRoundTrip(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var got []Fact
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/ingest/adventure" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var f Fact
|
||||
_ = json.Unmarshal(body, &f)
|
||||
mu.Lock()
|
||||
got = append(got, f)
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
std = &Client{cfg: Config{IngestURL: srv.URL, Token: "tok", Enabled: true}, http: srv.Client()}
|
||||
|
||||
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1})
|
||||
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) // dup guid
|
||||
|
||||
std.drain(context.Background())
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("delivered %d facts, want 1 (dup should collapse)", len(got))
|
||||
}
|
||||
if got[0].Subject != "Brannigan" {
|
||||
t.Errorf("subject = %q", got[0].Subject)
|
||||
}
|
||||
if gotAuth != "Bearer tok" {
|
||||
t.Errorf("auth header = %q", gotAuth)
|
||||
}
|
||||
|
||||
var pending int
|
||||
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE sent_at IS NULL`).Scan(&pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Errorf("%d rows still pending after successful drain", pending)
|
||||
}
|
||||
|
||||
// A second drain re-sends nothing.
|
||||
std.drain(context.Background())
|
||||
if len(got) != 1 {
|
||||
t.Errorf("re-drained a sent row: %d deliveries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitDisabledNoQueue: when disabled, Emit is a durable no-op.
|
||||
func TestEmitDisabledNoQueue(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
std = &Client{cfg: Config{Enabled: false}, http: http.DefaultClient}
|
||||
Emit(Fact{GUID: "disabled-guid", EventType: "death", OccurredAt: 1})
|
||||
var n int
|
||||
// db.Init is a process singleton, so this may share state with other tests;
|
||||
// scope the check to this fact's guid.
|
||||
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE guid = 'disabled-guid'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("disabled Emit queued %d rows, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@ package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -102,7 +100,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
||||
res, err := d.Exec(
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
||||
string(userID), achievementID,
|
||||
)
|
||||
@@ -110,43 +108,7 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st
|
||||
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
||||
return
|
||||
}
|
||||
// Only a genuinely new grant is news; a re-check conflict (rows==0) is not.
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return
|
||||
}
|
||||
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
|
||||
p.emitMilestoneNews(d, userID, achievementID)
|
||||
}
|
||||
|
||||
// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements
|
||||
// only — rarity-gated to a single holder (per the news selection thresholds) so
|
||||
// routine unlocks don't flood the feed. Character name only; no-op unless the
|
||||
// seam is enabled.
|
||||
func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) {
|
||||
var holders int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 {
|
||||
return
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
label := achievementID
|
||||
for _, a := range p.achievements {
|
||||
if a.ID == achievementID {
|
||||
label = a.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(userID, achievementID), achievementID),
|
||||
EventType: "milestone",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Milestone: label,
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||
@@ -1175,190 +1137,9 @@ 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
|
||||
},
|
||||
},
|
||||
// N7/B4 — the Renown wing (gogobee_engagement_plan.md §B4). Passive checks
|
||||
// against the derived Renown level (N7/B2); no event hook needed.
|
||||
{
|
||||
ID: "renown_1", Name: "Beyond the Cap", Description: "Earned your first level of Renown. The story was supposed to be over.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 1) },
|
||||
},
|
||||
{
|
||||
ID: "renown_5", Name: "Storied", Description: "Reached Renown 5. They tell versions of your runs that never happened.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "renown_10", Name: "Fabled", Description: "Reached Renown 10. Somewhere a bard is getting rich off your name.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 10) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// renownAtLeast reports whether the player's derived Renown level (N7/B2) has
|
||||
// reached level, reading player_meta.renown_xp through the passed handle.
|
||||
func renownAtLeast(d *sql.DB, userID id.UserID, level int) bool {
|
||||
var xp int
|
||||
if err := d.QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp); err != nil {
|
||||
return false
|
||||
}
|
||||
return renownLevelFor(xp) >= level
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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,9 +221,10 @@ 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, and to
|
||||
// before eventAnchoredCutoff so the legacy mutator path still fires.
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
// Backdate start so deliverBriefing's same-day guard passes.
|
||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
// Drive 3 daily briefings — verify supply burn + day advance.
|
||||
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
||||
@@ -304,7 +305,7 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
// Forage-friendly expedition.
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows lean"); err != nil {
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows"); err != nil {
|
||||
t.Fatalf("expedition start: %v", err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
|
||||
@@ -21,14 +21,13 @@ import (
|
||||
|
||||
type AdventurePlugin struct {
|
||||
Base
|
||||
euro *EuroPlugin
|
||||
xp *XPPlugin
|
||||
achievements *AchievementsPlugin
|
||||
mu sync.Mutex
|
||||
euro *EuroPlugin
|
||||
xp *XPPlugin
|
||||
achievements *AchievementsPlugin
|
||||
mu sync.Mutex
|
||||
dmToPlayer map[id.RoomID]id.UserID
|
||||
pending sync.Map // userID string -> *advPendingInteraction
|
||||
userLocks sync.Map // userID string -> *sync.Mutex
|
||||
expLocks sync.Map // expedition ID string -> *sync.Mutex
|
||||
dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
|
||||
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
|
||||
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
|
||||
@@ -38,8 +37,8 @@ type AdventurePlugin struct {
|
||||
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
|
||||
craftResults sync.Map // userID string -> []CraftResult (pending craft results for narrative)
|
||||
treasureUndo sync.Map // userID string -> *advTreasureUndoToken (10-min auto-swap reversal)
|
||||
morningHour int
|
||||
summaryHour int
|
||||
morningHour int
|
||||
summaryHour int
|
||||
|
||||
// stopCh is closed by Stop() to signal all background tickers
|
||||
// (morning/summary/midnight/event/rival/robbie/hospital/mortgage/
|
||||
@@ -71,16 +70,6 @@ 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
|
||||
@@ -169,8 +158,6 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
{Name: "rest", Description: "Rest up (`short`: quick breather, 1h cooldown — `long`: full night, 24h, needs housing or inn)", Usage: "!rest short|long", Category: "Games"},
|
||||
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
|
||||
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
|
||||
{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
|
||||
{Name: "mischief", Description: "Pay to send a monster after an adventurer who's out on an expedition", Usage: "!mischief send <tier> @user", Category: "Games"},
|
||||
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
|
||||
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
|
||||
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
||||
@@ -181,11 +168,6 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
{Name: "sell", Description: "Sell your hauled materials, fish, and items to Thom Krooke after an expedition (a smooth pitch can land a better price)", Usage: "!sell [list|all|<item>]", Category: "Games"},
|
||||
{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
|
||||
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"},
|
||||
{Name: "give", Description: "Gift a consumable or non-magic item to another adventurer (5% handling fee to the community pot; 3/day)", Usage: "!give <item> @user", Category: "Games"},
|
||||
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
|
||||
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
|
||||
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
|
||||
{Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +177,6 @@ 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()
|
||||
@@ -239,20 +220,6 @@ 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()
|
||||
// Pete adventure-news cold-start: replay the back-catalogue (realm-firsts,
|
||||
// deaths, single-holder achievements) the first boot the seam is live, so
|
||||
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
// 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
|
||||
@@ -264,11 +231,6 @@ func (p *AdventurePlugin) Init() error {
|
||||
// Revive any characters whose DeadUntil has expired
|
||||
p.catchUpRespawns(chars)
|
||||
|
||||
// Seed the boredom idle clock before the ticker that reads it can wake:
|
||||
// an unseeded column reads as "idle since character creation" for every
|
||||
// pre-existing player.
|
||||
bootstrapBoredomClock()
|
||||
|
||||
// Start schedulers — single shared stopCh allows graceful shutdown.
|
||||
if p.stopCh == nil {
|
||||
p.stopCh = make(chan struct{})
|
||||
@@ -286,16 +248,9 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.expeditionRecapTicker()
|
||||
go p.expeditionAmbientTicker()
|
||||
go p.expeditionAutoRunTicker()
|
||||
go p.peteRosterTicker()
|
||||
go p.expeditionExtractionSweepTicker()
|
||||
go p.expeditionBoredomTicker()
|
||||
go p.mischiefTicker()
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -324,21 +279,6 @@ 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())
|
||||
|
||||
// Bored adventurers (gogobee_boredom_plan.md §1) — the idle clock that
|
||||
// decides when a character gives up waiting and leaves on its own. Stamped
|
||||
// only for a real action against Adventure: idle chatter in a room is not
|
||||
// tending your adventurer, and no ticker or autopilot path may touch this,
|
||||
// or a bored character would refresh its own clock and never qualify again.
|
||||
if p.isPlayerAdventureAction(ctx) {
|
||||
markPlayerAction(ctx.Sender)
|
||||
}
|
||||
|
||||
// 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"))
|
||||
@@ -361,12 +301,6 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "rest") {
|
||||
return p.handleDnDRestCmd(ctx, p.GetArgs(ctx.Body, "rest"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "duel") {
|
||||
return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "mischief") {
|
||||
return p.handleMischiefCmd(ctx, p.GetArgs(ctx.Body, "mischief"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "arm") {
|
||||
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
|
||||
}
|
||||
@@ -391,9 +325,6 @@ 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)
|
||||
}
|
||||
@@ -450,23 +381,6 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "lore") {
|
||||
return p.handleLoreCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "give") {
|
||||
return p.handleGiveCmd(ctx)
|
||||
}
|
||||
|
||||
// 0b. Town registries (E3) — read-only social boards (rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "town") {
|
||||
return p.handleTownCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "graveyard") {
|
||||
return p.handleGraveyardCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "rivals") {
|
||||
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "news") {
|
||||
return p.handleNewsCmd(ctx)
|
||||
}
|
||||
|
||||
// 1. Arena commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "bail") {
|
||||
@@ -546,10 +460,6 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, advHelpText)
|
||||
case lower == "rivals":
|
||||
return p.handleRivalsCmd(ctx)
|
||||
case lower == "duel" || strings.HasPrefix(lower, "duel "):
|
||||
return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
|
||||
case lower == "mischief" || strings.HasPrefix(lower, "mischief "):
|
||||
return p.handleMischiefCmd(ctx, strings.TrimSpace(args[len("mischief"):]))
|
||||
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
|
||||
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
|
||||
case lower == "blacksmith" || lower == "repair":
|
||||
@@ -558,8 +468,6 @@ 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":
|
||||
@@ -568,16 +476,6 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.handleMasteryCmd(ctx)
|
||||
case lower == "treasures" || strings.HasPrefix(lower, "treasures "):
|
||||
return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures")))
|
||||
case lower == "vault" || strings.HasPrefix(lower, "vault "):
|
||||
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
|
||||
case lower == "journal":
|
||||
return p.handleJournalCmd(ctx)
|
||||
case lower == "shadow":
|
||||
return p.handleShadowCmd(ctx)
|
||||
case lower == "worldboss" || strings.HasPrefix(lower, "worldboss "):
|
||||
return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("worldboss"):]))
|
||||
case lower == "siege" || strings.HasPrefix(lower, "siege "):
|
||||
return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("siege"):]))
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
||||
@@ -595,24 +493,18 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
|
||||
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
|
||||
` + "`!adventure inventory`" + ` — View your inventory
|
||||
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
|
||||
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
||||
` + "`!adventure respond`" + ` — Respond to a mid-day event
|
||||
` + "`!adventure rivals`" + ` — View rival duel records
|
||||
` + "`!duel @user [stake]`" + ` — Challenge another adventurer to a staked, no-death bout (` + "`!duel accept`" + `/` + "`decline`" + `/` + "`status`" + `)
|
||||
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
|
||||
` + "`!adventure babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days)
|
||||
` + "`!adventure babysit focus <skill>`" + ` — Pick the skill auto-babysit trains (mining/fishing/foraging)
|
||||
` + "`!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
|
||||
` + "`!adventure journal`" + ` — Read the campaign pages you've recovered
|
||||
` + "`!adventure shadow`" + ` — See how your rival's run compares to yours
|
||||
` + "`!adventure worldboss`" + ` — The monthly Siege: one communal bout a day (` + "`fight`" + ` to join)
|
||||
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||
` + "`!adventure help`" + ` — This message
|
||||
@@ -677,7 +569,6 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
|
||||
@@ -800,10 +691,7 @@ func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
|
||||
} else {
|
||||
result = p.advSellItem(ctx.Sender, args)
|
||||
}
|
||||
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
|
||||
return p.SendDM(ctx.Sender, result)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {
|
||||
@@ -828,7 +716,6 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
FishingSkill: c.FishingSkill,
|
||||
CurrentStreak: c.CurrentStreak,
|
||||
Renown: renownLevelForUser(c.UserID),
|
||||
})
|
||||
}
|
||||
text := renderAdvLeaderboard(entries)
|
||||
@@ -947,10 +834,6 @@ 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":
|
||||
@@ -966,11 +849,11 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
case "npc_encounter":
|
||||
return p.resolveNPCEncounter(ctx, interaction)
|
||||
case "pet_arrival":
|
||||
return p.resolvePetArrival(ctx, interaction)
|
||||
return p.resolvePetArrival(ctx)
|
||||
case "pet_type":
|
||||
return p.resolvePetType(ctx, interaction)
|
||||
return p.resolvePetType(ctx)
|
||||
case "pet_name":
|
||||
return p.resolvePetName(ctx, interaction)
|
||||
return p.resolvePetName(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1173,19 +1056,13 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
|
||||
|
||||
// ── Treasure Drop Check ─────────────────────────────────────────────────────
|
||||
|
||||
// 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)
|
||||
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
|
||||
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID))
|
||||
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.
|
||||
//
|
||||
// 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 {
|
||||
if rate > 0 && roll < rate*2 {
|
||||
p.SendDM(userID, fmt.Sprintf("🎁 *Treasure: just missed* — rolled %.2f%% against %.2f%% drop chance.",
|
||||
roll*100, rate*100))
|
||||
}
|
||||
@@ -1198,7 +1075,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
|
||||
return
|
||||
}
|
||||
|
||||
if count < maxTreasuresForTier(char.HouseTier) {
|
||||
if count < advMaxTreasures {
|
||||
// Directly save
|
||||
if err := advSaveTreasure(userID, drop.Def); err != nil {
|
||||
slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
|
||||
@@ -1583,7 +1460,7 @@ func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier)))
|
||||
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleTreasuresCmd(ctx MessageContext, arg string) error {
|
||||
@@ -1645,3 +1522,4 @@ func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
|
||||
advSetBoost(true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
|
||||
}
|
||||
|
||||
|
||||
@@ -642,7 +642,7 @@ type AdvActionResult struct {
|
||||
FlavorKey string
|
||||
EquipBroken []EquipmentSlot
|
||||
NearDeath bool
|
||||
StreakBonus int
|
||||
StreakBonus int
|
||||
CombatLog *CombatResult
|
||||
// MasteryCrossings records equipment slots whose ActionsUsed crossed a
|
||||
// mastery threshold (advMasteryThresholds) on this action. The caller
|
||||
|
||||
@@ -116,12 +116,12 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
|
||||
}
|
||||
for rows.Next() {
|
||||
var (
|
||||
uid, zoneID string
|
||||
visitedJSON string
|
||||
totalRooms int
|
||||
abandoned, bossDefeated int
|
||||
completedAt *time.Time
|
||||
lastAction time.Time
|
||||
uid, zoneID string
|
||||
visitedJSON string
|
||||
totalRooms int
|
||||
abandoned, bossDefeated int
|
||||
completedAt *time.Time
|
||||
lastAction time.Time
|
||||
)
|
||||
if err := rows.Scan(&uid, &zoneID, &visitedJSON, &totalRooms,
|
||||
&abandoned, &bossDefeated, &completedAt, &lastAction); err != nil {
|
||||
|
||||
@@ -16,20 +16,20 @@ import (
|
||||
// ── Arena Run State ─────────────────────────────────────────────────────────
|
||||
|
||||
type ArenaRun struct {
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
RoomID id.RoomID
|
||||
StartTier int
|
||||
Tier int
|
||||
Round int
|
||||
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
|
||||
Earnings int64 // session total (multiplied euros accumulated across completed tiers)
|
||||
TierEarnings int64 // current tier's raw earnings (reset each tier)
|
||||
XPAccumulated int // session XP accumulator (raw, multiplied at payout)
|
||||
RoundsSurvived int
|
||||
LastMonster string
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
RoomID id.RoomID
|
||||
StartTier int
|
||||
Tier int
|
||||
Round int
|
||||
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
|
||||
Earnings int64 // session total (multiplied euros accumulated across completed tiers)
|
||||
TierEarnings int64 // current tier's raw earnings (reset each tier)
|
||||
XPAccumulated int // session XP accumulator (raw, multiplied at payout)
|
||||
RoundsSurvived int
|
||||
LastMonster string
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
|
||||
// ── Command Dispatch ────────────────────────────────────────────────────────
|
||||
@@ -304,17 +304,13 @@ 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 {
|
||||
now := time.Now().UTC()
|
||||
start, end := arenaSeasonBounds(now)
|
||||
entries, err := loadArenaSeasonLeaderboard(start, end)
|
||||
entries, err := loadArenaLeaderboard()
|
||||
if err != nil {
|
||||
slog.Error("arena: failed to load season leaderboard", "err", err)
|
||||
slog.Error("arena: failed to load leaderboard", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load arena leaderboard.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(arenaSeasonKey(now), entries))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(entries))
|
||||
}
|
||||
|
||||
// ── Combat Resolution ───────────────────────────────────────────────────────
|
||||
@@ -531,12 +527,6 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
}
|
||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
||||
|
||||
// N7/B3 the Omen — a payout-boosting week scales gross earnings before the
|
||||
// pot tax, so both the player's cut and the pot's rake grow proportionally.
|
||||
if m := activeOmen().ArenaPayoutMult; m > 1.0 {
|
||||
run.Earnings = int64(float64(run.Earnings) * m)
|
||||
}
|
||||
|
||||
// Arena tax: 10% of earnings to community pot.
|
||||
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
||||
arenaNet := run.Earnings - arenaTax
|
||||
@@ -634,10 +624,7 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
text += fmt.Sprintf("\n\n🎉 **Combat Level %d!**", newLevel)
|
||||
}
|
||||
|
||||
err := p.SendDM(userID, text)
|
||||
// N1/A6 — cashing out is the third mid-day event anchor.
|
||||
p.maybeFireAnchoredEvent(userID, advEventChanceArena)
|
||||
return err
|
||||
return p.SendDM(userID, text)
|
||||
}
|
||||
|
||||
// arenaProcessBail handles bail payout (called from handleArenaBail or countdown).
|
||||
@@ -1021,8 +1008,8 @@ func loadArenaPersonalStats(userID id.UserID) *ArenaPersonalStats {
|
||||
|
||||
type ArenaGearSet struct {
|
||||
Tier int
|
||||
SetKey string // DB key: "bloodied", "ironclad", etc.
|
||||
SetName string // Display: "Bloodied", "Ironclad", etc.
|
||||
SetKey string // DB key: "bloodied", "ironclad", etc.
|
||||
SetName string // Display: "Bloodied", "Ironclad", etc.
|
||||
HelmetName string
|
||||
Description string
|
||||
DropRate float64
|
||||
|
||||
@@ -138,12 +138,12 @@ func TestArenaBossOutcome_WinSurfacesStagedLogAndBossDeath(t *testing.T) {
|
||||
|
||||
victoryHeadline := "🏆 **" + monster.Name + "** falls (HP " + "60" + "→" + "42" + " / 60)."
|
||||
outcome := renderBossOutcome(BossOutcomeInputs{
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "arena-stagedlog-test",
|
||||
RoomIdx: 11,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: 60, PostHP: 42, MaxHP: 60,
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "arena-stagedlog-test",
|
||||
RoomIdx: 11,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: 60, PostHP: 42, MaxHP: 60,
|
||||
PhaseTwoAt: arenaBossPhaseTwoAt(1),
|
||||
Nat20s: 1,
|
||||
Nat1s: 0,
|
||||
|
||||
@@ -126,21 +126,21 @@ func renderArenaStatus(run *ArenaRun) string {
|
||||
// ── Leaderboard ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ArenaLeaderboardEntry struct {
|
||||
DisplayName string
|
||||
TotalEarnings int64
|
||||
HighestTier int
|
||||
DisplayName string
|
||||
TotalEarnings int64
|
||||
HighestTier int
|
||||
Tier5Completions int
|
||||
TotalRuns int
|
||||
TotalDeaths int
|
||||
TotalRuns int
|
||||
TotalDeaths int
|
||||
}
|
||||
|
||||
func renderArenaLeaderboard(season string, entries []ArenaLeaderboardEntry) string {
|
||||
func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
|
||||
if len(entries) == 0 {
|
||||
return fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\nNobody has entered the arena this season. Be the first.", season)
|
||||
return "⚔️ **Arena Leaderboard**\n\nNo arena runs recorded yet. Be the first."
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\n", season))
|
||||
b.WriteString("⚔️ **Arena Leaderboard**\n\n")
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
for i, e := range entries {
|
||||
@@ -157,7 +157,6 @@ func renderArenaLeaderboard(season string, entries []ArenaLeaderboardEntry) stri
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
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("2026-Q3", nil)
|
||||
if !strings.Contains(text, "Nobody has entered") {
|
||||
t.Error("empty leaderboard should say nobody entered")
|
||||
text := renderArenaLeaderboard(nil)
|
||||
if !strings.Contains(text, "No arena runs") {
|
||||
t.Error("empty leaderboard should say no runs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("2026-Q3", entries)
|
||||
text := renderArenaLeaderboard(entries)
|
||||
if !strings.Contains(text, "Alice") {
|
||||
t.Error("leaderboard should contain Alice")
|
||||
}
|
||||
|
||||
@@ -36,15 +36,22 @@ func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
|
||||
if !char.BabysitActive {
|
||||
return
|
||||
}
|
||||
// Both companions share the sitter's attention and gain the flat trickle.
|
||||
// (Combat only ever reads their *averaged* procs, so leveling both is not a
|
||||
// power spike.)
|
||||
leveled := false
|
||||
if char.HasPet() {
|
||||
leveled = advancePetLevelsFromXP(&char.PetXP, &char.PetLevel, &char.PetLevel10Date, petXPPerBabysitDay*100) || leveled
|
||||
}
|
||||
if char.HasPet2() {
|
||||
leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled
|
||||
if char.HasPet() && char.PetLevel < 10 {
|
||||
// Bypass petGrantXP's per-action constant — we want a flat trickle.
|
||||
char.PetXP += petXPPerBabysitDay * 100
|
||||
for char.PetLevel < 10 {
|
||||
needed := petXPToNextLevel(char.PetLevel) * 100
|
||||
if char.PetXP < needed {
|
||||
break
|
||||
}
|
||||
char.PetXP -= needed
|
||||
char.PetLevel++
|
||||
leveled = true
|
||||
}
|
||||
if char.PetLevel >= 10 && char.PetLevel10Date == "" {
|
||||
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
outcome := "pet_care"
|
||||
if leveled {
|
||||
@@ -91,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 to have downed the zone boss\n"+
|
||||
" • Standard camps act like fortified ones — rest deeply, no need for boss-cleared rooms\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"+
|
||||
@@ -311,15 +318,15 @@ func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) stri
|
||||
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
type babysitLogEntry struct {
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
LogDate string
|
||||
Activity string
|
||||
Outcome string
|
||||
GoldEarned int
|
||||
XPGained int
|
||||
ItemsDropped string
|
||||
RivalRefused string
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
LogDate string
|
||||
Activity string
|
||||
Outcome string
|
||||
GoldEarned int
|
||||
XPGained int
|
||||
ItemsDropped string
|
||||
RivalRefused string
|
||||
}
|
||||
|
||||
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {
|
||||
|
||||
@@ -66,11 +66,11 @@ func TestRunBabysitDailyTrickle_AccumulatesAndLevels(t *testing.T) {
|
||||
defer func() { _ = recover() }()
|
||||
|
||||
char := &AdventureCharacter{
|
||||
UserID: id.UserID("@accum:example"),
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
|
||||
PetXP: 900,
|
||||
UserID: id.UserID("@accum:example"),
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
|
||||
PetXP: 900,
|
||||
BabysitActive: true,
|
||||
}
|
||||
expires := time.Now().UTC().Add(48 * time.Hour)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -27,32 +24,32 @@ var allSlots = []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots, Slo
|
||||
// ── Core Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type AdventureCharacter struct {
|
||||
UserID id.UserID
|
||||
DisplayName string
|
||||
CombatLevel int
|
||||
MiningSkill int
|
||||
ForagingSkill int
|
||||
FishingSkill int // v2
|
||||
CombatXP int
|
||||
MiningXP int
|
||||
ForagingXP int
|
||||
FishingXP int // v2
|
||||
Alive bool
|
||||
DeadUntil *time.Time
|
||||
ActionTakenToday bool
|
||||
HolidayActionTaken bool
|
||||
CombatActionsUsed int
|
||||
HarvestActionsUsed int
|
||||
ArenaWins int // v2
|
||||
ArenaLosses int // v2
|
||||
InvasionScore int // v2
|
||||
Title string // v2
|
||||
CurrentStreak int
|
||||
BestStreak int
|
||||
LastActionDate string
|
||||
GrudgeLocation string
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
UserID id.UserID
|
||||
DisplayName string
|
||||
CombatLevel int
|
||||
MiningSkill int
|
||||
ForagingSkill int
|
||||
FishingSkill int // v2
|
||||
CombatXP int
|
||||
MiningXP int
|
||||
ForagingXP int
|
||||
FishingXP int // v2
|
||||
Alive bool
|
||||
DeadUntil *time.Time
|
||||
ActionTakenToday bool
|
||||
HolidayActionTaken bool
|
||||
CombatActionsUsed int
|
||||
HarvestActionsUsed int
|
||||
ArenaWins int // v2
|
||||
ArenaLosses int // v2
|
||||
InvasionScore int // v2
|
||||
Title string // v2
|
||||
CurrentStreak int
|
||||
BestStreak int
|
||||
LastActionDate string
|
||||
GrudgeLocation string
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
DeathReprieveLast *time.Time
|
||||
MasterworkDropsReceived int
|
||||
RivalPool int
|
||||
@@ -81,58 +78,27 @@ type AdventureCharacter struct {
|
||||
HouseAutopay bool
|
||||
HouseCurrentRate float64
|
||||
// Pets
|
||||
PetType string
|
||||
PetName string
|
||||
PetXP int
|
||||
PetLevel int
|
||||
PetArmorTier int
|
||||
PetChasedAway bool
|
||||
PetReactivated bool
|
||||
PetArrived bool
|
||||
MistyEncounterCount int
|
||||
MistyDonatedCount int
|
||||
ThomAnimalLineFired bool
|
||||
PetType string
|
||||
PetName string
|
||||
PetXP int
|
||||
PetLevel int
|
||||
PetArmorTier int
|
||||
PetChasedAway bool
|
||||
PetReactivated bool
|
||||
PetArrived bool
|
||||
MistyEncounterCount int
|
||||
MistyDonatedCount int
|
||||
ThomAnimalLineFired bool
|
||||
PetSupplyShopUnlocked bool
|
||||
PetLevel10Date string
|
||||
PetMorningDefense bool
|
||||
// Second pet (N4/E1, Tier-4 Estate). A parallel slot, not a rewrite of the
|
||||
// pet-1 fields above — see HasPet2. Carries only identity/level/barding; the
|
||||
// morning-defense, ditch-recovery and supply-shop mechanics stay pet-1-only.
|
||||
Pet2Type string
|
||||
Pet2Name string
|
||||
Pet2XP int
|
||||
Pet2Level int
|
||||
Pet2ArmorTier int
|
||||
Pet2ChasedAway bool
|
||||
Pet2Reactivated bool
|
||||
Pet2Arrived bool
|
||||
Pet2Level10Date string
|
||||
AutoBabysit bool
|
||||
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
|
||||
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
|
||||
StreakDecayed bool
|
||||
CraftsSucceeded int
|
||||
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
||||
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
||||
// N5/D1 the Hollow King campaign. Bitmask of discovered journal pages (bit i
|
||||
// == page i+1). Read-only overlay from player_meta.journal_pages; writes go
|
||||
// through the atomic grantJournalPageDB, never the bulk character save.
|
||||
JournalPages int64
|
||||
// N5/D1c the finale reward-once flag. True after the first finale clear;
|
||||
// overlay-read, written by the atomic markEpilogueClearedDB.
|
||||
EpilogueCleared bool
|
||||
// N7/B2 Renown — cumulative overflow XP earned past the L20 cap. Overlay-read
|
||||
// from player_meta.renown_xp, written by the atomic addRenownXP, never the
|
||||
// bulk character save. RenownLevel() derives the prestige level from it.
|
||||
RenownXP int
|
||||
}
|
||||
|
||||
// RenownLevel is the derived prestige level (renown_xp / renownXPPerLevel).
|
||||
func (c *AdventureCharacter) RenownLevel() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(c.RenownXP)
|
||||
PetLevel10Date string
|
||||
PetMorningDefense bool
|
||||
AutoBabysit bool
|
||||
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
|
||||
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
|
||||
StreakDecayed bool
|
||||
CraftsSucceeded int
|
||||
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
||||
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -155,7 +121,6 @@ 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 {
|
||||
@@ -304,11 +269,6 @@ func (c *AdventureCharacter) HasPet() bool {
|
||||
return c.PetType != "" && c.PetArrived && !c.PetChasedAway
|
||||
}
|
||||
|
||||
// HasPet2 returns true if the player has an active second pet.
|
||||
func (c *AdventureCharacter) HasPet2() bool {
|
||||
return c.Pet2Type != "" && c.Pet2Arrived && !c.Pet2ChasedAway
|
||||
}
|
||||
|
||||
// HasHouse returns true if the player has purchased at least a base house.
|
||||
func (c *AdventureCharacter) HasHouse() bool {
|
||||
return c.HouseTier > 0 || c.HouseLoanBalance > 0
|
||||
@@ -351,30 +311,7 @@ func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
|
||||
}
|
||||
|
||||
func (c *AdventureCharacter) HasActedToday() bool {
|
||||
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)
|
||||
return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0
|
||||
}
|
||||
|
||||
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {
|
||||
@@ -539,41 +476,6 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ensurePlayerMetaSeed guarantees the canonical player_meta seed row (and tier-0
|
||||
// equipment) exists for userID, creating it only when absent. The auto-migration
|
||||
// path (ensureDnDCharacterForCombat) writes a confirmed dnd_character without
|
||||
// touching the legacy layer; without this, a brand-new player whose first-ever
|
||||
// action auto-migrates — e.g. !rest or !cast before !setup — ends up with a
|
||||
// player_meta-less character that fails every legacy-layer command with
|
||||
// "sql: no rows" (the camcast straggler). Conditional on absence and thus
|
||||
// idempotent: legacy players who already have player_meta keep their equipment
|
||||
// untouched — createAdvCharacter's tier-0 equipment insert has no conflict guard
|
||||
// and would otherwise duplicate their gear.
|
||||
func ensurePlayerMetaSeed(userID id.UserID) error {
|
||||
d := db.Get()
|
||||
var one int
|
||||
err := d.QueryRow(`SELECT 1 FROM player_meta WHERE user_id = ?`, string(userID)).Scan(&one)
|
||||
if err == nil {
|
||||
return nil // already seeded
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
return createAdvCharacter(userID, localpartOf(userID))
|
||||
}
|
||||
|
||||
// localpartOf returns the mxid localpart (between @ and :) as a display-name
|
||||
// fallback — matches Base.DisplayName's offline behavior. The seed's display
|
||||
// name is overlaid by later player_meta upserts; this is just a sane default
|
||||
// for a character born without a Matrix client in reach.
|
||||
func localpartOf(userID id.UserID) string {
|
||||
s := string(userID)
|
||||
if i := strings.Index(s, ":"); i > 0 {
|
||||
return s[1:i]
|
||||
}
|
||||
return strings.TrimPrefix(s, "@")
|
||||
}
|
||||
|
||||
// saveAdvCharacter persists every mutable AdventureCharacter field to
|
||||
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
|
||||
// retired — the row is now read-only after createAdvCharacter seeds it,
|
||||
@@ -635,8 +537,8 @@ 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, temper
|
||||
FROM adventure_inventory WHERE user_id = ? AND in_vault = 0
|
||||
SELECT id, name, item_type, tier, value, slot, skill_source
|
||||
FROM adventure_inventory WHERE user_id = ?
|
||||
ORDER BY tier DESC, value DESC`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -647,7 +549,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, &it.Temper); err != nil {
|
||||
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Slot = EquipmentSlot(slot)
|
||||
@@ -659,19 +561,9 @@ 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, 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)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -687,10 +579,7 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
return nil, err
|
||||
}
|
||||
d := db.Get()
|
||||
// Delete only what loadAdvInventory returned — vaulted rows are out of play
|
||||
// and must survive a bulk clear, or the vault's whole promise (safe from
|
||||
// !sell all) breaks the moment any caller routes sell-all through here.
|
||||
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID))
|
||||
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -700,62 +589,10 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
func advInventoryCount(userID id.UserID) int {
|
||||
d := db.Get()
|
||||
var count int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID)).Scan(&count)
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ?`, string(userID)).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// loadAdvVault returns the items the player has stowed in their housing vault
|
||||
// (N4/E1). These are excluded from loadAdvInventory — a vaulted item is out of
|
||||
// play (unsellable, uncraftable, unusable in combat) until it is taken back.
|
||||
func loadAdvVault(userID id.UserID) ([]AdvItem, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT id, name, item_type, tier, value, slot, skill_source, temper
|
||||
FROM adventure_inventory WHERE user_id = ? AND in_vault = 1
|
||||
ORDER BY tier DESC, value DESC`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []AdvItem
|
||||
for rows.Next() {
|
||||
var it AdvItem
|
||||
var slot string
|
||||
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)
|
||||
items = append(items, it)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// advVaultCount returns how many of a user's items are stowed in the vault.
|
||||
func advVaultCount(userID id.UserID) int {
|
||||
d := db.Get()
|
||||
var count int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 1`, string(userID)).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// setItemVaulted flips a single inventory row's vault flag. Scoped to the
|
||||
// owning user so a mistyped id can never move another player's item.
|
||||
func setItemVaulted(userID id.UserID, itemID int64, vaulted bool) (bool, error) {
|
||||
v := 0
|
||||
if vaulted {
|
||||
v = 1
|
||||
}
|
||||
res, err := db.Get().Exec(
|
||||
`UPDATE adventure_inventory SET in_vault = ? WHERE id = ? AND user_id = ?`,
|
||||
v, itemID, string(userID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT user_id FROM player_meta`)
|
||||
|
||||
@@ -1,702 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Pete, the realm's embedded correspondent — the hireable NPC companion.
|
||||
// (pete_adventure_news_plan.md, "Pete as a character", surface 2.)
|
||||
//
|
||||
// A leader short a body can hire Pete into an expedition party. He fills the
|
||||
// role the party is missing, fights on autopilot, and files a dispatch about it
|
||||
// afterwards.
|
||||
//
|
||||
// The load-bearing rule, and the reason this file exists rather than a
|
||||
// player_meta row for @pete: **Pete is not a player and must never become one.**
|
||||
// He has no player_meta, no dnd_character, no inventory, no euros, and no DM
|
||||
// room. Mint him a player_meta row and ensureDnDCharacterForCombat will happily
|
||||
// auto-build him a real character on his first swing, at which point he shows up
|
||||
// in the graveyard, the leaderboards, the news as a subject, and the daily event
|
||||
// rolls — all of which would be a bot reporting on itself.
|
||||
//
|
||||
// So his seat is synthesized: companionCombatant builds a Combatant in memory
|
||||
// from the same tuned layers a player's sheet goes through, and every seam that
|
||||
// assumes a seat is a person is guarded by isCompanionSeat. The guards live at
|
||||
// four chokepoints, which between them cover the whole blast radius:
|
||||
//
|
||||
// - expeditionAudience — he is never in the DM fan-out (and so never in the
|
||||
// per-member pet-arrival rolls or the daily event rolls that ride it)
|
||||
// - partySize — he is not a mouth: he doesn't inflate the supply
|
||||
// burn he never bought packs for, and an NPC-only roster doesn't lock the
|
||||
// leader out of their next expedition
|
||||
// - partyCombatantsForSession / the seat builders — synthesize, don't load
|
||||
// - the close-out loops — no XP, no loot, no death row, no achievements
|
||||
//
|
||||
// He does count toward the enemy-HP scalar, because he is a body in the fight
|
||||
// and the boss can feel him.
|
||||
|
||||
// companionUserIDDefault is Pete's real Matrix account. He is an independent bot
|
||||
// (his own repo, his own voice); gogobee already ignores him as a sender via
|
||||
// IGNORED_BOTS, so seating him here can never round-trip into command handling.
|
||||
// PETE_USER_ID overrides for a differently-homed deployment.
|
||||
const companionUserIDDefault = "@pete:parodia.dev"
|
||||
|
||||
// companionDisplayName is what the party sees on his seat. gogobee names him but
|
||||
// does NOT voice him: his hire banter and his dispatch are written by his own
|
||||
// bot from the fact emitted below (project_pete_bot_architecture).
|
||||
const companionDisplayName = "Pete"
|
||||
|
||||
// Hire pricing. The plan files cost as an open tuning question; this is the
|
||||
// first answer, not the final one. It scales with both the level he shows up at
|
||||
// and the tier he's walking into, so hiring him for a T5 boss run is not the
|
||||
// same 300 coins as a T1 stroll. Supply packs are 50/90 coins for reference —
|
||||
// he is deliberately a real expense, not a rounding error.
|
||||
//
|
||||
// PROD WATCH: at endgame coin balances this is likely too cheap to be a sink.
|
||||
// Raise companionHireCoinsPerLevel before raising the base — the base is what a
|
||||
// low-level player short a friend has to find.
|
||||
const (
|
||||
companionHireBaseCoins = 300
|
||||
companionHireCoinsPerLevel = 60
|
||||
)
|
||||
|
||||
// companionLevelPenalty is what makes him help rather than carry. He arrives one
|
||||
// level below the party's average — a competent below-median member, per the
|
||||
// difficulty plan's standing rule: lift the trailing case, never nerf the
|
||||
// leaders and never touch monster scaling.
|
||||
const companionLevelPenalty = 1
|
||||
|
||||
var (
|
||||
ErrCompanionAlreadyHired = errors.New("pete is already with this party")
|
||||
ErrCompanionOnAssignment = errors.New("pete is out on assignment")
|
||||
ErrCompanionNotHired = errors.New("pete is not with this party")
|
||||
)
|
||||
|
||||
// companionUserID resolves Pete's Matrix id.
|
||||
func companionUserID() id.UserID {
|
||||
if v := strings.TrimSpace(os.Getenv("PETE_USER_ID")); v != "" {
|
||||
return id.UserID(v)
|
||||
}
|
||||
return id.UserID(companionUserIDDefault)
|
||||
}
|
||||
|
||||
// isCompanionSeat reports whether a roster seat / combat seat is Pete rather
|
||||
// than a player. This is the predicate every guard in the codebase keys on.
|
||||
func isCompanionSeat(userID id.UserID) bool { return userID == companionUserID() }
|
||||
|
||||
// isCompanionUser is the string-keyed form, for the many seams that carry a raw
|
||||
// user_id out of the database.
|
||||
func isCompanionUser(userID string) bool { return id.UserID(userID) == companionUserID() }
|
||||
|
||||
// companionHireCost is what the leader pays to bring him along, in coins.
|
||||
func companionHireCost(level int, tier ZoneTier) int {
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
t := int(tier)
|
||||
if t < 1 {
|
||||
t = 1
|
||||
}
|
||||
return (companionHireBaseCoins + companionHireCoinsPerLevel*level) * t
|
||||
}
|
||||
|
||||
// ── who he shows up as ───────────────────────────────────────────────────────
|
||||
|
||||
// companionRoleFill picks the class Pete plays. He is role-fluid — that is the
|
||||
// whole point of hiring him — so by default he fills the hole in the roster: no
|
||||
// healer, he's a Cleric; no damage, he's a Mage; nobody up front, he's a
|
||||
// Fighter. A leader who knows better can override.
|
||||
//
|
||||
// The order of the checks is the priority order: a party with neither a healer
|
||||
// nor a front line gets the healer, because a party that cannot heal is the one
|
||||
// that dies.
|
||||
func companionRoleFill(partyClasses []DnDClass) DnDClass {
|
||||
var hasHealer, hasFront, hasDamage bool
|
||||
for _, c := range partyClasses {
|
||||
switch c {
|
||||
case ClassCleric, ClassDruid, ClassBard:
|
||||
hasHealer = true
|
||||
case ClassPaladin:
|
||||
// The one chassis that answers two questions at once.
|
||||
hasHealer, hasFront = true, true
|
||||
case ClassFighter:
|
||||
hasFront = true
|
||||
case ClassMage, ClassSorcerer, ClassWarlock, ClassRogue, ClassRanger:
|
||||
hasDamage = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !hasHealer:
|
||||
return ClassCleric
|
||||
case !hasFront:
|
||||
return ClassFighter
|
||||
case !hasDamage:
|
||||
return ClassMage
|
||||
default:
|
||||
// A complete party that hires him anyway gets a second pair of hands up
|
||||
// front — the least redundant thing he can be.
|
||||
return ClassFighter
|
||||
}
|
||||
}
|
||||
|
||||
// parseCompanionClass resolves an explicit `!expedition hire cleric` override.
|
||||
// Empty (or unknown) means auto-fill.
|
||||
func parseCompanionClass(arg string) (DnDClass, bool) {
|
||||
arg = strings.ToLower(strings.TrimSpace(arg))
|
||||
if arg == "" || arg == "auto" {
|
||||
return "", false
|
||||
}
|
||||
for _, ci := range dndClasses {
|
||||
if !ci.Playable {
|
||||
continue
|
||||
}
|
||||
if string(ci.Key) == arg || strings.EqualFold(ci.Display, arg) {
|
||||
return ci.Key, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// companionHumans is every *player* on the expedition — the roster if one exists,
|
||||
// and otherwise the owner alone.
|
||||
//
|
||||
// The fallback is the whole point. A solo expedition has NO expedition_party rows
|
||||
// (see partyMembers: absence means solo, and the roster only materializes on the
|
||||
// first successful invite). Reading the roster alone therefore answers "nobody" for
|
||||
// exactly the player this feature exists for: the one with no friends around, who
|
||||
// is hiring Pete *because* they are alone.
|
||||
//
|
||||
// Getting this wrong is not a small error. It hired every solo player a **level-1**
|
||||
// Pete — in a tier-4 zone, against a boss that had just gained 15% HP and a full
|
||||
// extra set of actions to account for him. He died on contact and left the leader
|
||||
// fighting an inflated boss alone. A 1500-run sweep measured it: solo 65% clear,
|
||||
// two humans 87%, solo+Pete 33%. The companion was worse than no companion, and
|
||||
// this line is why.
|
||||
func companionHumans(expeditionID string) []*DnDCharacter {
|
||||
var owner string
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT user_id FROM dnd_expedition WHERE expedition_id = ?`,
|
||||
expeditionID).Scan(&owner); err != nil {
|
||||
return nil
|
||||
}
|
||||
seats, err := partyHumans(expeditionID, owner)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]*DnDCharacter, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
if dc, _ := LoadDnDCharacter(s.UserID); dc != nil {
|
||||
out = append(out, dc)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// companionPartyLevel is the level he arrives at: the party's average, less
|
||||
// companionLevelPenalty, floored at 1.
|
||||
func companionPartyLevel(expeditionID string) int {
|
||||
chars := companionHumans(expeditionID)
|
||||
if len(chars) == 0 {
|
||||
return 1
|
||||
}
|
||||
sum := 0
|
||||
for _, dc := range chars {
|
||||
sum += dc.Level
|
||||
}
|
||||
lvl := sum/len(chars) - companionLevelPenalty
|
||||
if lvl < 1 {
|
||||
lvl = 1
|
||||
}
|
||||
return lvl
|
||||
}
|
||||
|
||||
// companionPartyClasses reads the classes already on the expedition, for the role
|
||||
// fill. Solo resolves to the owner's class, so a lone fighter gets a healer rather
|
||||
// than the empty-party default.
|
||||
func companionPartyClasses(expeditionID string) []DnDClass {
|
||||
chars := companionHumans(expeditionID)
|
||||
out := make([]DnDClass, 0, len(chars))
|
||||
for _, dc := range chars {
|
||||
out = append(out, dc.Class)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── his sheet, which lives only in memory ────────────────────────────────────
|
||||
|
||||
// companionSheet synthesizes the DnDCharacter Pete fights as. It is built, used,
|
||||
// and thrown away inside a single combat build — it is never saved, and
|
||||
// SaveDnDCharacter must never be called on it.
|
||||
//
|
||||
// Stats come from the same class-priority + race-mod pipeline autoBuildCharacter
|
||||
// uses for a real character, so he is statted like a player of his level rather
|
||||
// than by a bespoke NPC table that would drift away from the tuned math. Human
|
||||
// is deliberate: the +1-to-all is the most neutral race in the book, so his
|
||||
// class is doing the work rather than a race pick nobody chose.
|
||||
func companionSheet(class DnDClass, level int) *DnDCharacter {
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
scores := applyRaceMods(RaceHuman, classStatPriority(class))
|
||||
c := &DnDCharacter{
|
||||
UserID: companionUserID(),
|
||||
Race: RaceHuman,
|
||||
Class: class,
|
||||
Level: level,
|
||||
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
return c
|
||||
}
|
||||
|
||||
// companionAdvCharacter is the AdventureCharacter half of the synthetic sheet:
|
||||
// the shim DerivePlayerStats needs. CombatLevel round-trips back to the D&D
|
||||
// level through dndLevelFromCombatLevel (level*5), so the two halves agree.
|
||||
func companionAdvCharacter(level int) *AdventureCharacter {
|
||||
return &AdventureCharacter{
|
||||
UserID: companionUserID(),
|
||||
DisplayName: companionDisplayName,
|
||||
CombatLevel: level * 5,
|
||||
Alive: true,
|
||||
}
|
||||
}
|
||||
|
||||
// companionGearTier maps his level onto the equipment tier a player of that
|
||||
// level would plausibly be carrying: 1–4 → T1, 5–8 → T2, and so on to T5.
|
||||
func companionGearTier(level int) int {
|
||||
t := (level + 3) / 4
|
||||
if t < 1 {
|
||||
t = 1
|
||||
}
|
||||
if t > 5 {
|
||||
t = 5
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// companionGear is the kit he walks in with — a working reporter's kit, bought
|
||||
// with expenses and kept in serviceable shape.
|
||||
//
|
||||
// He has to have one. The gear layer is not decoration: unarmored, stats.AC is
|
||||
// never set at all (computeArmorAC only fires when armor exists) and he walks in
|
||||
// at AC 3, hit by everything; weaponless, stats.Weapon stays nil and he swings
|
||||
// for a flat 5 at every level, which by L14 is nothing. "No gear" is not a
|
||||
// below-median player, it is a broken one.
|
||||
//
|
||||
// The below-median comes from everywhere else: he is a level down, his gear is
|
||||
// never Masterwork, and he carries no magic items, no subclass and no armed
|
||||
// ability. The weapon names are chosen to hit the right branch of
|
||||
// synthesizeWeaponProfile for the class — it best-fits off the name.
|
||||
func companionGear(class DnDClass, level int) map[EquipmentSlot]*AdvEquipment {
|
||||
tier := companionGearTier(level)
|
||||
|
||||
weapon := "Service Mace"
|
||||
switch class {
|
||||
case ClassFighter, ClassPaladin:
|
||||
weapon = "Service Sword"
|
||||
case ClassRogue:
|
||||
weapon = "Service Dagger"
|
||||
case ClassRanger:
|
||||
weapon = "Service Bow"
|
||||
case ClassMage, ClassSorcerer, ClassWarlock, ClassDruid, ClassBard:
|
||||
weapon = "Service Staff"
|
||||
}
|
||||
|
||||
return map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Slot: SlotWeapon, Tier: tier, Condition: 100, Name: weapon},
|
||||
SlotArmor: {Slot: SlotArmor, Tier: tier, Condition: 100, Name: "Service Kit"},
|
||||
}
|
||||
}
|
||||
|
||||
// companionCombatant builds Pete's seat for a fight, mirroring buildZoneCombatants
|
||||
// but sourcing the sheet from memory instead of the database. He carries no
|
||||
// equipment, no treasure bonuses, no magic items, no chat level and no streak —
|
||||
// the three layers a player accumulates and he never will. That absence *is* the
|
||||
// below-median: he is a bare class chassis at a level below yours, and the gap
|
||||
// between him and a geared player of the same level is exactly the gear.
|
||||
func (p *AdventurePlugin) companionCombatant(
|
||||
class DnDClass, level int, monster DnDMonsterTemplate, tier int, dmMood int,
|
||||
) (Combatant, Combatant, *DnDCharacter) {
|
||||
tilt := dmMoodCombatTilt(dmMood)
|
||||
char := companionAdvCharacter(level)
|
||||
dc := companionSheet(class, level)
|
||||
|
||||
// The layer order is buildZoneCombatants', deliberately — a companion statted
|
||||
// by a different pipeline would drift away from the tuned math the moment
|
||||
// anyone touched one and not the other.
|
||||
//
|
||||
// What he does NOT get is the subclass layer, magic items, and an armed
|
||||
// ability: three of the things a player accumulates and a hireling never will.
|
||||
// Those absences, plus the level penalty and gear that is never Masterwork,
|
||||
// are the "below median" — see companionGear for why the gear itself is not
|
||||
// one of the things we take away.
|
||||
gear := companionGear(class, level)
|
||||
stats, mods := DerivePlayerStats(char, gear, &AdvBonusSummary{}, 0, 0, false)
|
||||
applyDnDPlayerLayer(&stats, dc)
|
||||
applyDnDEquipmentLayer(&stats, dc, gear)
|
||||
applyDnDHPScaling(&stats, dc)
|
||||
applyClassPassives(&stats, &mods, dc)
|
||||
applyRacePassives(&stats, &mods, dc)
|
||||
|
||||
enemyStats, enemyMods := monster.toCombatStats()
|
||||
if tier > 1 {
|
||||
if floorAC := dndDungeonACBase + tier; enemyStats.AC < floorAC {
|
||||
enemyStats.AC = floorAC
|
||||
}
|
||||
if floorAB := dndDungeonAtkBase + tier; enemyStats.AttackBonus < floorAB {
|
||||
enemyStats.AttackBonus = floorAB
|
||||
}
|
||||
}
|
||||
enemyStats.Attack += tilt.EnemyAttackDelta
|
||||
if enemyStats.Attack < 1 {
|
||||
enemyStats.Attack = 1
|
||||
}
|
||||
mods.InitiativeBias += tilt.InitiativeBias
|
||||
|
||||
player := Combatant{
|
||||
Name: companionDisplayName,
|
||||
Stats: stats,
|
||||
Mods: mods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: monster.Name,
|
||||
Stats: enemyStats,
|
||||
Mods: enemyMods,
|
||||
Ability: monster.Ability,
|
||||
}
|
||||
return player, enemy, dc
|
||||
}
|
||||
|
||||
// companionRosterLine is how he reads on `!expedition party`. DisplayName would
|
||||
// come back empty for him — he has no player_meta row to hold a name — so the
|
||||
// roster names him here, along with what he is currently playing, because "Pete
|
||||
// (member)" tells the leader nothing about the hole they paid to fill.
|
||||
func companionRosterLine(expeditionID string) string {
|
||||
class, level := companionLoadout(expeditionID)
|
||||
ci, _ := classInfo(class)
|
||||
return fmt.Sprintf("**%s** _(hired — level %d %s)_\n", companionDisplayName, level, ci.Display)
|
||||
}
|
||||
|
||||
// ── the hire, persisted on the roster ────────────────────────────────────────
|
||||
|
||||
// companionLoadout reads back the class and level he was hired at. It is stored
|
||||
// on the roster row rather than re-derived per fight, so a party that levels
|
||||
// mid-expedition doesn't quietly re-roll their hireling into a different class
|
||||
// three rooms in.
|
||||
func companionLoadout(expeditionID string) (DnDClass, int) {
|
||||
var class string
|
||||
var level int
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT companion_class, companion_level
|
||||
FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&class, &level)
|
||||
if err != nil || class == "" {
|
||||
return ClassFighter, 1
|
||||
}
|
||||
return DnDClass(class), level
|
||||
}
|
||||
|
||||
// ── his spell slots, which live on the expedition ────────────────────────────
|
||||
//
|
||||
// A human caster's slots are dnd_spell_slots rows: one pool, spent across every
|
||||
// fight of the run, refilled only at camp. Rationing it is the caster's game. The
|
||||
// companion has no rows, so his pool lives on his roster row — the same row his
|
||||
// class and level live on, and with the same lifetime.
|
||||
//
|
||||
// It must NOT live on his combat seat. A seat is per-session and every fight opens
|
||||
// a new one, so a seat-scoped pool refills itself between fights: an infinite
|
||||
// caster. That is not a theory — the first cut did exactly that, and the sim
|
||||
// measured a gearless, level-penalized hireling out-clearing a human cleric of the
|
||||
// leader's own level by 15pp.
|
||||
|
||||
// companionSlotsCSV encodes/decodes the ledger. CSV of six ints rather than JSON
|
||||
// because it is six ints.
|
||||
func companionSlotsDecode(s string) [6]int {
|
||||
var out [6]int
|
||||
for i, f := range strings.Split(s, ",") {
|
||||
if i >= len(out) {
|
||||
break
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(f))
|
||||
if err != nil || n < 0 {
|
||||
continue
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func companionSlotsEncode(used [6]int) string {
|
||||
parts := make([]string, len(used))
|
||||
for i, n := range used {
|
||||
parts[i] = strconv.Itoa(n)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// companionSlotsForRun reads the ledger for the companion on the expedition that
|
||||
// owns runID. A run with no companion (or no expedition) reads as an empty pool,
|
||||
// which is the correct answer: nobody spent anything.
|
||||
func companionSlotsForRun(runID string) [6]int {
|
||||
var raw string
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT p.companion_slots_used
|
||||
FROM expedition_party p
|
||||
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||
WHERE e.run_id = ? AND p.user_id = ?`,
|
||||
runID, string(companionUserID())).Scan(&raw)
|
||||
if err != nil {
|
||||
return [6]int{}
|
||||
}
|
||||
return companionSlotsDecode(raw)
|
||||
}
|
||||
|
||||
// setCompanionSlotsForRun writes it back.
|
||||
func setCompanionSlotsForRun(runID string, used [6]int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party
|
||||
SET companion_slots_used = ?
|
||||
WHERE user_id = ?
|
||||
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
|
||||
companionSlotsEncode(used), string(companionUserID()), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshCompanionSlots empties the ledger — his half of the camp rest that calls
|
||||
// refreshSpellSlots for every human. Keyed by expedition, because camp is.
|
||||
func refreshCompanionSlots(expeditionID string) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_slots_used = ''
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// ── his body, which is also carried across the run ───────────────────────────
|
||||
//
|
||||
// companionUnsetHP is "no wound recorded" — a fresh hire, or a companion who has
|
||||
// just broken camp. Seating reads it as full.
|
||||
const companionUnsetHP = -1
|
||||
|
||||
// companionHPFor reads the HP he carries into his next fight, or companionUnsetHP
|
||||
// when he is unhurt. A run with no companion reads unset, which is harmless: there
|
||||
// is nobody to seat.
|
||||
func companionHPFor(expeditionID string) int {
|
||||
hp := companionUnsetHP
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT companion_hp FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&hp)
|
||||
if err != nil {
|
||||
return companionUnsetHP
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
// companionSeatHP is what he actually sits down with: his carried wound, clamped
|
||||
// into [1, maxHP].
|
||||
//
|
||||
// The floor of 1 is deliberate. He can be dropped *inside* a fight — the engine
|
||||
// counts him out like any other seat — but he does not stay dead between them,
|
||||
// because there is no companion-death mechanic and inventing one here would be a
|
||||
// second feature smuggled into a bug fix. Coming back on 1 HP is a real penalty
|
||||
// (one hit and he is down again) without pretending to be a corpse rule.
|
||||
func companionSeatHP(expeditionID string, maxHP int) int {
|
||||
hp := companionHPFor(expeditionID)
|
||||
if hp == companionUnsetHP || hp > maxHP {
|
||||
return maxHP
|
||||
}
|
||||
if hp < 1 {
|
||||
return 1
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
// setCompanionHP records the HP he walked out of a fight with.
|
||||
func setCompanionHP(expeditionID string, hp int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_hp = ?
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
hp, expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// setCompanionHPForRun is setCompanionHP for the turn-based close-out, which
|
||||
// holds a run id rather than an expedition id.
|
||||
func setCompanionHPForRun(runID string, hp int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party
|
||||
SET companion_hp = ?
|
||||
WHERE user_id = ?
|
||||
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
|
||||
hp, string(companionUserID()), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshCompanionHP is his half of the camp heal: back to full, like every human
|
||||
// at a standard rest.
|
||||
func refreshCompanionHP(expeditionID string) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_hp = ?
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
companionUnsetHP, expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// companionLoadoutForRun is companionLoadout keyed by the zone run instead of
|
||||
// the expedition. A CombatSession carries a RunID, not an expedition id, and the
|
||||
// per-turn rebuild is the hottest caller — so the join lives here rather than
|
||||
// making every caller resolve the expedition first.
|
||||
//
|
||||
// A run with no expedition (a standalone !zone run, which can have no companion)
|
||||
// finds no row and falls back, which is the correct reading.
|
||||
func companionLoadoutForRun(runID string) (DnDClass, int) {
|
||||
var class string
|
||||
var level int
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT p.companion_class, p.companion_level
|
||||
FROM expedition_party p
|
||||
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||
WHERE e.run_id = ? AND p.user_id = ?`,
|
||||
runID, string(companionUserID())).Scan(&class, &level)
|
||||
if err != nil || class == "" {
|
||||
return ClassFighter, 1
|
||||
}
|
||||
return DnDClass(class), level
|
||||
}
|
||||
|
||||
// companionExpeditionFor resolves the expedition a leader is running, for the
|
||||
// seat builder — which has the roster but not the expedition id. Empty string
|
||||
// when there is none, which companionLoadout reads as "no row" and falls back.
|
||||
func companionExpeditionFor(leader id.UserID) string {
|
||||
e, _, err := activeExpeditionFor(leader)
|
||||
if err != nil || e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
// companionSeated reports whether Pete is on this roster.
|
||||
func companionSeated(expeditionID string) bool {
|
||||
var one int
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT 1 FROM expedition_party WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&one)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// hireCompanion seats Pete. The seat check, the availability check and the
|
||||
// insert share a transaction, so two leaders racing for him cannot both win.
|
||||
//
|
||||
// He is globally exclusive — one party at a time. That is not a limitation to
|
||||
// route around: "Pete is out on assignment" is the scarcity knob the plan asks
|
||||
// for, and it is also why he cannot be the answer to every run.
|
||||
func hireCompanion(expeditionID string, class DnDClass, level int) error {
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := seatLeader(tx, expeditionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Is he already out with somebody? Scoped to live expeditions, so a roster
|
||||
// row stranded by a crash cannot make him unhireable forever.
|
||||
var busyOn string
|
||||
err = tx.QueryRow(`
|
||||
SELECT p.expedition_id
|
||||
FROM expedition_party p
|
||||
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||
WHERE p.user_id = ? AND e.status IN ('active', 'extracting')
|
||||
LIMIT 1`, string(companionUserID())).Scan(&busyOn)
|
||||
switch {
|
||||
case err == nil && busyOn == expeditionID:
|
||||
return ErrCompanionAlreadyHired
|
||||
case err == nil:
|
||||
return ErrCompanionOnAssignment
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return err
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
|
||||
expeditionID).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n >= expeditionPartyMax {
|
||||
return ErrPartyFull
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO expedition_party (expedition_id, user_id, role, companion_class, companion_level)
|
||||
VALUES (?, ?, 'member', ?, ?)`,
|
||||
expeditionID, string(companionUserID()), string(class), level); err != nil {
|
||||
return fmt.Errorf("seat companion: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// dismissCompanion sends him home. Unlike a player member he can be removed
|
||||
// mid-run — he is staff, not a guest — but the fee is not refunded: he already
|
||||
// walked in.
|
||||
func dismissCompanion(expeditionID string) error {
|
||||
res, err := db.Get().Exec(`
|
||||
DELETE FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrCompanionNotHired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── the dispatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
// emitCompanionHireFact tells Pete's bot he has been hired. gogobee states the
|
||||
// fact; Pete writes the sentence. Bulletin, not priority: a hire is a diary
|
||||
// entry, not a bulletin-interrupting event.
|
||||
//
|
||||
// The subject is the *leader*, so the leader's news opt-out is honoured — Pete
|
||||
// reporting "filled in as cleric for <someone who asked not to be named>" would
|
||||
// be exactly the leak the opt-out exists to prevent. Pete himself is never a
|
||||
// subject: he has no opt-out row and needs none.
|
||||
func emitCompanionHireFact(leader id.UserID, class DnDClass, level int, zone ZoneDefinition) {
|
||||
name := charName(leader)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
ci, _ := classInfo(class)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: "companion_hire:" + eventToken(leader, "companion_hire") + ":" + fmt.Sprint(time.Now().UTC().Unix()),
|
||||
EventType: "companion_hire",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Level: level,
|
||||
ClassRace: ci.Display,
|
||||
OccurredAt: time.Now().UTC().Unix(),
|
||||
}, leader, "")
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The companion's whole contract is "he fights, and he is not a player". These
|
||||
// tests pin both halves — and specifically the seams where an NPC seat would
|
||||
// otherwise be silently treated as a person.
|
||||
|
||||
func TestCompanion_HiredSeatIsNotAMouth(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@leader:example.org")
|
||||
seedExpedition(t, "exp-hire", owner, "active")
|
||||
seatLeaderFixture(t, "exp-hire")
|
||||
|
||||
if err := hireCompanion("exp-hire", ClassCleric, 4); err != nil {
|
||||
t.Fatalf("hireCompanion: %v", err)
|
||||
}
|
||||
|
||||
// The roster holds two seats...
|
||||
members, err := partyMembers("exp-hire")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("roster has %d seats, want 2 (leader + companion)", len(members))
|
||||
}
|
||||
|
||||
// ...but only one of them eats. partySize feeds the daily supply burn and the
|
||||
// "your party is still waiting on you" lock-out; counting the companion would
|
||||
// bill the leader for rations he never bought, and strand him out of his next
|
||||
// expedition behind a party of one bot.
|
||||
n, err := partySize("exp-hire")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("partySize = %d, want 1 — the companion is not a mouth", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanion_GetsNoMailButTakesASeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@leader2:example.org")
|
||||
seedExpedition(t, "exp-mail", owner, "active")
|
||||
seatLeaderFixture(t, "exp-mail")
|
||||
if err := hireCompanion("exp-mail", ClassFighter, 3); err != nil {
|
||||
t.Fatalf("hireCompanion: %v", err)
|
||||
}
|
||||
exp, err := getExpedition("exp-mail")
|
||||
if err != nil || exp == nil {
|
||||
t.Fatalf("getExpedition: %v", err)
|
||||
}
|
||||
|
||||
// Mail and seats are different sets. Every DM seam reads the audience; the
|
||||
// combat roster reads the seats. Getting this backwards either DMs a bot or
|
||||
// charges a leader for a body that never sits down.
|
||||
for _, uid := range expeditionAudience(exp) {
|
||||
if isCompanionSeat(uid) {
|
||||
t.Fatal("companion is in the DM audience — he does not get mail")
|
||||
}
|
||||
}
|
||||
var seated bool
|
||||
for _, uid := range expeditionSeats(exp) {
|
||||
if isCompanionSeat(uid) {
|
||||
seated = true
|
||||
}
|
||||
}
|
||||
if !seated {
|
||||
t.Fatal("companion is not in the fight roster — he was paid for and never sat down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanion_IsGloballyExclusive(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
for _, e := range []struct {
|
||||
id string
|
||||
owner id.UserID
|
||||
}{{"exp-a", "@a:example.org"}, {"exp-b", "@b:example.org"}} {
|
||||
seedExpedition(t, e.id, e.owner, "active")
|
||||
seatLeaderFixture(t, e.id)
|
||||
}
|
||||
|
||||
if err := hireCompanion("exp-a", ClassMage, 5); err != nil {
|
||||
t.Fatalf("first hire: %v", err)
|
||||
}
|
||||
// He is one person. A second party cannot have him — "out on assignment" is
|
||||
// the scarcity knob, not a bug to route around.
|
||||
if err := hireCompanion("exp-b", ClassMage, 5); !errors.Is(err, ErrCompanionOnAssignment) {
|
||||
t.Errorf("second hire err = %v, want ErrCompanionOnAssignment", err)
|
||||
}
|
||||
// Re-hiring him into the party he's already with is its own answer.
|
||||
if err := hireCompanion("exp-a", ClassMage, 5); !errors.Is(err, ErrCompanionAlreadyHired) {
|
||||
t.Errorf("re-hire err = %v, want ErrCompanionAlreadyHired", err)
|
||||
}
|
||||
|
||||
// Dismissed, he's available again.
|
||||
if err := dismissCompanion("exp-a"); err != nil {
|
||||
t.Fatalf("dismiss: %v", err)
|
||||
}
|
||||
if err := hireCompanion("exp-b", ClassMage, 5); err != nil {
|
||||
t.Errorf("hire after dismiss: %v, want success", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The bug that made the whole feature worse than useless: a SOLO expedition has
|
||||
// no expedition_party rows at all (the roster only materializes on the first
|
||||
// invite), so reading the roster to size the companion answered "nobody" for
|
||||
// exactly the player who is hiring him — the one with no friends around. Every
|
||||
// solo hire got a level-1 Pete, in whatever tier the leader was actually in.
|
||||
func TestCompanion_SoloLeaderSizesHimCorrectly(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@lonely:example.org")
|
||||
seedExpedition(t, "exp-solo-hire", owner, "active")
|
||||
|
||||
// A level-12 fighter, adventuring alone. No roster rows exist.
|
||||
if err := SaveDnDCharacter(&DnDCharacter{
|
||||
UserID: owner, Race: RaceHuman, Class: ClassFighter, Level: 12,
|
||||
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := companionPartyLevel("exp-solo-hire"); got != 11 {
|
||||
t.Errorf("companionPartyLevel = %d, want 11 (the lone leader's 12, less the below-median step). "+
|
||||
"A level-1 companion in the leader's zone is worse than no companion at all.", got)
|
||||
}
|
||||
// And he fills the hole the lone fighter actually has, rather than defaulting
|
||||
// as if the party were empty.
|
||||
if got := companionRoleFill(companionPartyClasses("exp-solo-hire")); got != ClassCleric {
|
||||
t.Errorf("role fill for a lone fighter = %v, want cleric", got)
|
||||
}
|
||||
}
|
||||
|
||||
// §4 — asking for "the party" must never answer "nobody".
|
||||
//
|
||||
// A solo expedition has no expedition_party rows (absence means solo; the roster
|
||||
// materializes on the first invite). Every consumer that read the roster table
|
||||
// directly therefore got an empty list for a solo player and fell back to
|
||||
// whatever looked reasonable locally. That is how the companion was hired at
|
||||
// level 1 for exactly the player the feature exists for.
|
||||
//
|
||||
// expeditionParty always includes the owner. If this test ever fails, that
|
||||
// guarantee is gone and the same class of bug is back.
|
||||
func TestParty_SoloExpeditionStillHasAParty(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@alone:example.org")
|
||||
seedExpedition(t, "exp-alone", owner, "active")
|
||||
|
||||
// No roster rows exist at all.
|
||||
rows, err := partyMembers("exp-alone")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("solo expedition has %d roster rows; the premise of this test is gone", len(rows))
|
||||
}
|
||||
|
||||
// And yet the party is not empty.
|
||||
seats, err := expeditionParty("exp-alone", string(owner))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seats) != 1 || seats[0].UserID != owner || seats[0].Kind != SeatLeader {
|
||||
t.Fatalf("expeditionParty on a solo run = %+v, want exactly the owner as leader. "+
|
||||
"An empty answer here is what hires a level-1 companion.", seats)
|
||||
}
|
||||
humans, err := partyHumans("exp-alone", string(owner))
|
||||
if err != nil || len(humans) != 1 {
|
||||
t.Fatalf("partyHumans on a solo run = %+v (err %v), want the owner", humans, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanion_RoleFillTakesTheHole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
party []DnDClass
|
||||
want DnDClass
|
||||
}{
|
||||
{"no healer", []DnDClass{ClassFighter, ClassRogue}, ClassCleric},
|
||||
{"no front line", []DnDClass{ClassCleric, ClassMage}, ClassFighter},
|
||||
{"no damage", []DnDClass{ClassCleric, ClassFighter}, ClassMage},
|
||||
// A paladin covers healer AND front line, so paladin+rogue has no hole at
|
||||
// all — it falls through to the default, a second body up front.
|
||||
{"a complete party gets the default", []DnDClass{ClassPaladin, ClassRogue}, ClassFighter},
|
||||
{"a paladin still leaves the damage hole", []DnDClass{ClassPaladin}, ClassMage},
|
||||
{"solo fighter needs the medic first", []DnDClass{ClassFighter}, ClassCleric},
|
||||
{"empty party", nil, ClassCleric},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := companionRoleFill(tc.party); got != tc.want {
|
||||
t.Errorf("companionRoleFill(%v) = %v, want %v", tc.party, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// §3 — an engine-driven seat's latch is permanent, and no command can take it.
|
||||
//
|
||||
// This is the invariant that, when it did not exist, made the companion stand in
|
||||
// fights doing nothing. The expedition autopilot drives a party by dispatching
|
||||
// each seat's turn AS that seat; his own auto-played move therefore arrived at
|
||||
// beginCombatTurn looking exactly like a human returning to the keyboard, and the
|
||||
// "they typed, so they're here" branch cleared the latch that was moving him.
|
||||
// After round 1 he never acted again — while the boss he had inflated by 15% HP
|
||||
// killed the party. The sweep measured it at -27pp clear rate.
|
||||
//
|
||||
// Autopilot is provisional (a human is away; a keystroke ends it). EngineDriven is
|
||||
// not (there is nobody to come back). They must never collapse into one flag.
|
||||
func TestCompanion_EngineSeatKeepsTheWheel(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
uid := id.UserID("@lead:example.org")
|
||||
|
||||
monster := dndBestiary["goblin"]
|
||||
c, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
|
||||
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
|
||||
|
||||
sess, err := p.startPartyCombatSession("run-e", "enc", "goblin", &enemy, []CombatSeatSetup{
|
||||
{UserID: uid, HP: 200, HPMax: 200, Mods: c.Mods, C: &c},
|
||||
{UserID: companionUserID(), HP: 120, HPMax: 120, Mods: c.Mods, C: &c, EngineDriven: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !sess.seatIsEngineDriven(1) {
|
||||
t.Fatal("companion seat is not engine-driven")
|
||||
}
|
||||
if sess.seatIsEngineDriven(0) {
|
||||
t.Fatal("the human's seat came out engine-driven")
|
||||
}
|
||||
// The engine drives it without waiting on anybody...
|
||||
if !sess.seatIsAutopiloted(1) || !sess.seatNeedsNoHuman(1) {
|
||||
t.Fatal("an engine seat must resolve without waiting for a human")
|
||||
}
|
||||
// ...and the human's seat still waits for its human.
|
||||
if sess.seatIsAutopiloted(0) {
|
||||
t.Fatal("the human's seat was latched onto autopilot at seating")
|
||||
}
|
||||
|
||||
// The wheel cannot be taken back, because there is nobody to take it. This is
|
||||
// the exact line that used to strand him: a "keystroke" from his own id.
|
||||
sess.actorStatusesPtr(1).Autopilot = false
|
||||
if !sess.seatIsAutopiloted(1) {
|
||||
t.Fatal("clearing Autopilot stranded the engine seat — EngineDriven must keep it moving")
|
||||
}
|
||||
}
|
||||
|
||||
// The one that actually matters: he has to FIGHT. Every other guard in this
|
||||
// file is about keeping him out of things, and a companion synthesized down to
|
||||
// zeroed stats would pass all of them while standing in the fight doing nothing
|
||||
// — a hire that silently buys the leader an empty chair.
|
||||
func TestCompanion_ActuallySwings(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
monster := dndBestiary["goblin"]
|
||||
tank := monster
|
||||
tank.HP = 5000 // outlast the phase clock, so a real swing has time to land
|
||||
|
||||
pete, _, _ := p.companionCombatant(ClassFighter, 6, tank, 2, 0)
|
||||
|
||||
if pete.Stats.MaxHP <= 0 || pete.Stats.Attack <= 0 || pete.Stats.AC <= 0 {
|
||||
t.Fatalf("companion built with dead stats (%d HP / %d atk / %d AC) — he'd stand in the fight and do nothing",
|
||||
pete.Stats.MaxHP, pete.Stats.Attack, pete.Stats.AC)
|
||||
}
|
||||
|
||||
enemy := Combatant{Name: tank.Name, Stats: CombatStats{MaxHP: 5000, AC: 10, Attack: 1, AttackBonus: 1}}
|
||||
res := simulatePartyWithRNG([]Combatant{pete}, enemy, dungeonCombatPhases, seededRNG(7))
|
||||
|
||||
var swings int
|
||||
for _, e := range res.Events {
|
||||
if e.Actor == "player" && e.Seat == 0 && e.Roll > 0 {
|
||||
swings++
|
||||
}
|
||||
}
|
||||
if swings == 0 {
|
||||
t.Fatal("the companion never swung — he was hired, seated, and did nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanion_SheetIsBelowMedianAndNeverPersisted(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
|
||||
// He is statted like a player of his level, so he never drifts from the tuned
|
||||
// math — but he arrives a level down, which is the whole "help, never a carry"
|
||||
// rule expressed in one number.
|
||||
dc := companionSheet(ClassFighter, 7)
|
||||
if dc.Level != 7 || dc.HPMax <= 0 {
|
||||
t.Fatalf("companionSheet = level %d / %d HP, want a real level-7 chassis", dc.Level, dc.HPMax)
|
||||
}
|
||||
|
||||
// And he leaves no trace: a player_meta row is the thing that would turn him
|
||||
// into a real character everywhere (graveyard, news, XP, leaderboards), so the
|
||||
// synthesis must not have written one.
|
||||
if c, _ := LoadDnDCharacter(companionUserID()); c != nil {
|
||||
t.Fatal("companion has a persisted dnd_character row — he is a player now, which is the one thing he must never be")
|
||||
}
|
||||
if _, err := loadAdvCharacter(companionUserID()); err == nil {
|
||||
t.Fatal("companion has a persisted player_meta row — ensureDnDCharacterForCombat will auto-build him a character on his first swing")
|
||||
}
|
||||
}
|
||||
@@ -17,26 +17,26 @@ import (
|
||||
type ConsumableEffect string
|
||||
|
||||
const (
|
||||
EffectHeal ConsumableEffect = "heal"
|
||||
EffectDefBoost ConsumableEffect = "def_boost"
|
||||
EffectAtkBoost ConsumableEffect = "atk_boost"
|
||||
EffectWard ConsumableEffect = "ward"
|
||||
EffectHeal ConsumableEffect = "heal"
|
||||
EffectDefBoost ConsumableEffect = "def_boost"
|
||||
EffectAtkBoost ConsumableEffect = "atk_boost"
|
||||
EffectWard ConsumableEffect = "ward"
|
||||
EffectSpeedBoost ConsumableEffect = "speed_boost"
|
||||
EffectCritBoost ConsumableEffect = "crit_boost"
|
||||
EffectFlatDmg ConsumableEffect = "flat_dmg"
|
||||
EffectSpore ConsumableEffect = "spore"
|
||||
EffectReflect ConsumableEffect = "reflect"
|
||||
EffectAutoCrit ConsumableEffect = "auto_crit"
|
||||
EffectCritBoost ConsumableEffect = "crit_boost"
|
||||
EffectFlatDmg ConsumableEffect = "flat_dmg"
|
||||
EffectSpore ConsumableEffect = "spore"
|
||||
EffectReflect ConsumableEffect = "reflect"
|
||||
EffectAutoCrit ConsumableEffect = "auto_crit"
|
||||
)
|
||||
|
||||
type ConsumableDef struct {
|
||||
Name string
|
||||
Effect ConsumableEffect
|
||||
Value float64 // meaning depends on effect
|
||||
Tier int
|
||||
Buyable bool
|
||||
Price int64
|
||||
Slot string // "offensive" or "defensive"
|
||||
Name string
|
||||
Effect ConsumableEffect
|
||||
Value float64 // meaning depends on effect
|
||||
Tier int
|
||||
Buyable bool
|
||||
Price int64
|
||||
Slot string // "offensive" or "defensive"
|
||||
}
|
||||
|
||||
// ConsumableItem is an AdvItem that has consumable properties.
|
||||
@@ -234,13 +234,8 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
|
||||
if rand.Float64() >= 0.15 {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
name := names[rand.IntN(len(names))]
|
||||
def := consumableDefByName(name)
|
||||
if def == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -257,23 +252,6 @@ func consumableAdvItem(def *ConsumableDef) *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
|
||||
@@ -346,34 +324,15 @@ var craftingRecipes = []CraftingRecipe{
|
||||
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
|
||||
}
|
||||
|
||||
// houseTierWorkshop is the T3 "Comfortable" house that unlocks the workshop —
|
||||
// a flat craft-success bonus while crafting at home.
|
||||
const houseTierWorkshop = 3
|
||||
|
||||
// homeWorkshopCraftBonus returns the additive craft-success bonus from a T3+
|
||||
// home workshop. Tier 1/2 and no house grant none.
|
||||
func homeWorkshopCraftBonus(houseTier int) float64 {
|
||||
if houseTier >= houseTierWorkshop {
|
||||
return 0.05
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// craftingSuccessRate returns the success chance for a recipe given foraging
|
||||
// level. Base rate is 50% at minimum level, +3% per 5 levels above minimum,
|
||||
// capped at 95%. A T3+ home workshop adds workshopBonus on top and lifts the
|
||||
// cap to 98%, so a maxed forager still gains a little from the bench.
|
||||
func craftingSuccessRate(foragingLevel, minForaging int, workshopBonus float64) float64 {
|
||||
// craftingSuccessRate returns the success chance for a recipe given foraging level.
|
||||
// Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%.
|
||||
func craftingSuccessRate(foragingLevel, minForaging int) float64 {
|
||||
base := 0.50
|
||||
levelsAbove := foragingLevel - minForaging
|
||||
bonus := float64(levelsAbove) / 5.0 * 0.03
|
||||
rate := base + bonus + workshopBonus
|
||||
cap := 0.95
|
||||
if workshopBonus > 0 {
|
||||
cap = 0.98
|
||||
}
|
||||
if rate > cap {
|
||||
return cap
|
||||
rate := base + bonus
|
||||
if rate > 0.95 {
|
||||
return 0.95
|
||||
}
|
||||
return rate
|
||||
}
|
||||
@@ -388,7 +347,7 @@ type CraftResult struct {
|
||||
// their current foraging level, plus a teaser line for the next unlock
|
||||
// threshold. Hides exact ingredient lists for recipes the player hasn't
|
||||
// unlocked — only the count of locked recipes is shown.
|
||||
func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
|
||||
func renderRecipesKnown(foragingLevel int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
|
||||
|
||||
@@ -396,9 +355,6 @@ func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
|
||||
sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._")
|
||||
return sb.String()
|
||||
}
|
||||
if workshopBonus > 0 {
|
||||
sb.WriteString(fmt.Sprintf("_🔨 Home workshop: +%.0f%% craft success._\n\n", workshopBonus*100))
|
||||
}
|
||||
|
||||
// Group recipes by tier, list those known.
|
||||
known := map[int][]CraftingRecipe{}
|
||||
@@ -424,7 +380,7 @@ func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
|
||||
for _, r := range recipes {
|
||||
rate := craftingSuccessRate(foragingLevel, r.MinForaging, workshopBonus) * 100
|
||||
rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100
|
||||
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
|
||||
r.Result, strings.Join(r.Ingredients, " + "), rate))
|
||||
}
|
||||
@@ -455,7 +411,7 @@ var craftXPFailure = map[int]int{1: 3, 2: 5, 3: 8, 4: 12, 5: 18}
|
||||
//
|
||||
// Side effects: grants foraging XP per attempt (success > failure) and bumps
|
||||
// the player's CraftsSucceeded counter on each success.
|
||||
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int, workshopBonus float64) ([]CraftResult, []AdvItem) {
|
||||
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) ([]CraftResult, []AdvItem) {
|
||||
if foragingLevel < 10 {
|
||||
return nil, items
|
||||
}
|
||||
@@ -474,7 +430,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int,
|
||||
break
|
||||
}
|
||||
|
||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus)
|
||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
|
||||
success := rand.Float64() < rate
|
||||
|
||||
if success {
|
||||
|
||||
@@ -1,718 +0,0 @@
|
||||
package plugin
|
||||
|
||||
// C2 — player-initiated duels (engagement plan N6).
|
||||
//
|
||||
// `!duel @user [stake]` — a staked, no-death arena bout between two real
|
||||
// characters, resolved through the auto-resolve combat engine. Both players
|
||||
// escrow the stake on accept; the winner takes 70% of the pooled 2×stake and
|
||||
// the community pot rakes the remaining 30% (a real pot sink). Records land in
|
||||
// adventure_rival_records, so one W/L history and one 7-day per-pair cooldown
|
||||
// covers both duel kinds (the bot-driven RPS rival and this).
|
||||
//
|
||||
// Why this is a wrapper and not a combat-engine change: the turn engine is an
|
||||
// N-players-vs-1-monster model — there is no seat for a second real character
|
||||
// on the enemy side (see the C2 feasibility note in the engagement plan). So a
|
||||
// duel does not seat two PCs against each other. Instead each fighter is built
|
||||
// as their real player Combatant (full kit: weapon, subclass passives, extra
|
||||
// attacks, race traits) and the *opponent* is synthesised as a monster-shaped
|
||||
// enemy stat block from their own sheet. That is asymmetric — the "player" side
|
||||
// runs its full kit while the synthesised enemy is a flat stat block — so a
|
||||
// single bout would systematically favour whoever is the attacker. The duel
|
||||
// runs the bout in BOTH orientations and decides on the aggregate HP margin,
|
||||
// which cancels that edge symmetrically. To-hit is faithful either way (both
|
||||
// sides roll d20 + AttackBonus vs AC); only damage-per-hit is folded into the
|
||||
// synthesised enemy's flat Attack. Class-vs-class PvP balance is explicitly out
|
||||
// of scope (bragging rights, capped stakes).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
duelMinLevel = 3 // both duellists must be at least this level
|
||||
duelAcceptWindow = 24 * time.Hour // an unanswered challenge expires and refunds
|
||||
duelStakePerLvl = 500 // stake cap = challenger level × this
|
||||
duelMinStake = 100 // floor so a "duel" always has something on it
|
||||
duelWinnerPct = 0.70 // winner's cut of the pooled 2×stake; pot rakes the rest
|
||||
)
|
||||
|
||||
// advDuelChallenge is one outstanding duel invitation. Unlike the RPS rival
|
||||
// challenge it carries no per-round score — a combat duel resolves in a single
|
||||
// step on accept. The challenger's stake is already escrowed (debited) when the
|
||||
// row exists; the challenged player's is debited on accept.
|
||||
type advDuelChallenge struct {
|
||||
ChallengeID string
|
||||
ChallengerID id.UserID
|
||||
ChallengedID id.UserID
|
||||
Stake int
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
func insertDuelChallenge(c *advDuelChallenge) {
|
||||
db.Exec("duel: insert challenge",
|
||||
`INSERT INTO adventure_duel_challenges
|
||||
(challenge_id, challenger_id, challenged_id, stake, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID), c.Stake, c.ExpiresAt,
|
||||
)
|
||||
}
|
||||
|
||||
func deleteDuelChallenge(challengeID string) {
|
||||
db.Exec("duel: delete challenge",
|
||||
`DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID)
|
||||
}
|
||||
|
||||
// claimDuelChallenge atomically deletes the row and reports whether THIS caller
|
||||
// is the one that removed it. It is the single serialization point for a
|
||||
// challenge's terminal fate: accept, decline and the expiry ticker all race to
|
||||
// claim it, and exactly one wins. Only the winner moves euros — that is what
|
||||
// stops a stake being both resolved and refunded across the 24h boundary, or a
|
||||
// double `!duel decline` double-refunding. Mirrors communityPotDebit's
|
||||
// RowsAffected gate.
|
||||
func claimDuelChallenge(challengeID string) bool {
|
||||
res := db.ExecResult("duel: claim challenge",
|
||||
`DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID)
|
||||
if res == nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1
|
||||
}
|
||||
|
||||
func scanDuelChallenge(row interface{ Scan(...any) error }) (*advDuelChallenge, error) {
|
||||
c := &advDuelChallenge{}
|
||||
err := row.Scan(&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// pendingDuelForChallenged returns the live (unexpired) challenge awaiting this
|
||||
// player's answer, or nil. Expired rows are ignored here and swept by the
|
||||
// ticker, so a player is never shown a challenge they can no longer accept.
|
||||
func pendingDuelForChallenged(userID id.UserID) *advDuelChallenge {
|
||||
c := &advDuelChallenge{}
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
|
||||
FROM adventure_duel_challenges
|
||||
WHERE challenged_id = ? AND expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY created_at DESC LIMIT 1`, string(userID)).Scan(
|
||||
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// duelInvolves reports whether the player has any live challenge in flight, as
|
||||
// challenger or challenged. Used to enforce one duel at a time per player — the
|
||||
// escrow makes overlapping challenges a way to double-commit the same euros.
|
||||
func duelInvolves(userID id.UserID) bool {
|
||||
var n int
|
||||
db.Get().QueryRow(`
|
||||
SELECT COUNT(*) FROM adventure_duel_challenges
|
||||
WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
|
||||
string(userID), string(userID)).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func loadExpiredDuelChallenges() []advDuelChallenge {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
|
||||
FROM adventure_duel_challenges WHERE expires_at <= CURRENT_TIMESTAMP`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []advDuelChallenge
|
||||
for rows.Next() {
|
||||
if c, err := scanDuelChallenge(rows); err == nil {
|
||||
out = append(out, *c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Stake / gating ───────────────────────────────────────────────────────────
|
||||
|
||||
// duelMaxStake caps the stake at level × duelStakePerLvl so a strong player
|
||||
// can't farm a runaway pot off a weaker one, per the plan's imbalance cap.
|
||||
func duelMaxStake(level int) int { return level * duelStakePerLvl }
|
||||
|
||||
// duelPayout splits the pooled 2×stake: the winner takes duelWinnerPct, the
|
||||
// community pot rakes the remainder. Both fighters escrowed stake, so the
|
||||
// winner's net gain is winnerShare − stake and the pot is a real sink.
|
||||
func duelPayout(stake int) (winnerShare, potShare int) {
|
||||
pool := stake * 2
|
||||
winnerShare = int(math.Round(float64(pool) * duelWinnerPct))
|
||||
potShare = pool - winnerShare
|
||||
return
|
||||
}
|
||||
|
||||
// duelEligible reports whether a character can duel at all: alive, set up, and
|
||||
// at least duelMinLevel. Returns a player-facing reason when it can't.
|
||||
func (p *AdventurePlugin) duelEligible(char *AdventureCharacter) (int, string) {
|
||||
if char == nil {
|
||||
return 0, "no adventurer"
|
||||
}
|
||||
if !char.Alive {
|
||||
return 0, "dead"
|
||||
}
|
||||
lvl := rivalLevelForUser(char)
|
||||
if lvl < duelMinLevel {
|
||||
return lvl, fmt.Sprintf("below level %d", duelMinLevel)
|
||||
}
|
||||
return lvl, ""
|
||||
}
|
||||
|
||||
// ── Combat synthesis ─────────────────────────────────────────────────────────
|
||||
|
||||
// buildDuelist derives a fighter's full player Combatant — the same build a
|
||||
// zone encounter uses, so weapon, subclass passives, extra attacks and race
|
||||
// traits all apply — then resets it to full health. A duel is a fresh arena
|
||||
// bout, not a continuation of a wounded expedition, so wound carry-over is
|
||||
// dropped (StartHP=0 = "enter at MaxHP"). Mood 50 is the neutral band: no
|
||||
// initiative bias, no enemy-attack tilt.
|
||||
//
|
||||
// The armed-ability slot is left empty: actively-armed one-shots (a Berserker's
|
||||
// rage) don't fire, but always-on passives (Extra Attack, Sneak Attack, Divine
|
||||
// Strike, race traits) still layer in via the class/subclass passive builders.
|
||||
func (p *AdventurePlugin) buildDuelist(userID id.UserID) (Combatant, error) {
|
||||
var noMonster DnDMonsterTemplate
|
||||
player, _, _, err := p.buildZoneCombatants(userID, noMonster, 1, 50, "")
|
||||
if err != nil {
|
||||
return Combatant{}, err
|
||||
}
|
||||
player.Stats.StartHP = 0
|
||||
return player, nil
|
||||
}
|
||||
|
||||
// duelPerHitDamage is a fighter's expected damage on a single landed hit, used
|
||||
// to size the flat Attack of their synthesised enemy stat block. It mirrors the
|
||||
// player damage path's terms that the enemy path can't express: weapon dice
|
||||
// average (or the legacy flat Attack for a weaponless build), the always-on
|
||||
// per-hit riders (Divine Strike, Sneak Attack, Hunter's Mark, rage), then the
|
||||
// multiplicative damage bonus. Crit chance and once-per-fight openers are not
|
||||
// modelled — a coarse mean is enough for a capped-stakes bout decided over two
|
||||
// orientations.
|
||||
func duelPerHitDamage(pc Combatant) float64 {
|
||||
base := float64(pc.Stats.Attack)
|
||||
if w := pc.Stats.Weapon; w != nil {
|
||||
if pc.Stats.TwoHandedMode && w.HasProperty(PropVersatile) && w.VersaCount > 0 {
|
||||
base = float64(w.VersaCount)*(float64(w.VersaSides)+1)/2 +
|
||||
float64(pc.Stats.AbilityModForDamage) + float64(w.MagicBonus)
|
||||
} else {
|
||||
base = avgWeaponDamage(w, pc.Stats.AbilityModForDamage)
|
||||
}
|
||||
}
|
||||
riders := float64(pc.Mods.DivineStrikePerHit)
|
||||
riders += float64(pc.Mods.SneakAttackDie) * 3.5
|
||||
riders += float64(pc.Mods.HuntersMarkDie) * 3.5
|
||||
if pc.Mods.BerserkerRage {
|
||||
riders += float64(pc.Mods.RageMeleeDmg)
|
||||
}
|
||||
perHit := (base + riders) * (1 + pc.Mods.DamageBonus)
|
||||
if perHit < 1 {
|
||||
perHit = 1
|
||||
}
|
||||
return perHit
|
||||
}
|
||||
|
||||
// duelPerRoundProcDamage is the expected per-round damage from a fighter's
|
||||
// companion strikes — a Cleric's spiritual weapon and a Beastmaster's pet —
|
||||
// which land once per round on a proc roll rather than per weapon hit. The live
|
||||
// fighter rolls these natively in the engine; folding their expectation into the
|
||||
// synth enemy's round damage keeps both orientations comparable for those
|
||||
// builds (each formula is Dmg + d5, mean +3).
|
||||
func duelPerRoundProcDamage(pc Combatant) float64 {
|
||||
dmg := pc.Mods.SpiritWeaponProc * (float64(pc.Mods.SpiritWeaponDmg) + 3)
|
||||
dmg += pc.Mods.PetAttackProc * (float64(pc.Mods.PetAttackDmg) + 3)
|
||||
return dmg
|
||||
}
|
||||
|
||||
// synthDuelEnemy turns a fighter's player Combatant into the monster-shaped
|
||||
// enemy the opposite fighter faces. HP, AC and AttackBonus carry over verbatim
|
||||
// (to-hit stays faithful — the enemy rolls d20 + AttackBonus vs the attacker's
|
||||
// AC exactly as the player does). DamageReduct carries over too, so a
|
||||
// Barbarian's / Monk's flat mitigation is as tanky as the enemy as it is as the
|
||||
// live PC. Offense is the lossy term: the enemy path deals a flat Attack, so a
|
||||
// whole round — weapon swings (per-hit × (1 + extra attacks)) plus the expected
|
||||
// companion-strike damage — is folded into one enemy Attack, preserving expected
|
||||
// round damage while widening variance (a wash across the two orientations).
|
||||
//
|
||||
// Deliberately NOT modelled on the enemy side (accepted scope — the plan frames
|
||||
// duels as bragging rights and puts class-vs-class PvP balance explicitly out of
|
||||
// scope): reactive one-shots the enemy engine has no channel for — heal items,
|
||||
// wards, the Sovereign death-save, the arcane ward buffer — and spell damage,
|
||||
// which buildZoneCombatants never wires for the live caster either, so a caster
|
||||
// fights weapon-only in both orientations. Defense is dropped: the weapon-damage
|
||||
// path players use reads DamageReduct, not the enemy's Defense stat.
|
||||
func synthDuelEnemy(pc Combatant) Combatant {
|
||||
swings := 1 + pc.Mods.ExtraAttacks
|
||||
roundDmg := duelPerHitDamage(pc)*float64(swings) + duelPerRoundProcDamage(pc)
|
||||
return Combatant{
|
||||
Name: pc.Name,
|
||||
IsPlayer: false,
|
||||
Stats: CombatStats{
|
||||
MaxHP: pc.Stats.MaxHP,
|
||||
AC: pc.Stats.AC,
|
||||
AttackBonus: pc.Stats.AttackBonus,
|
||||
Attack: int(math.Round(roundDmg)),
|
||||
},
|
||||
Mods: CombatModifiers{DamageReduct: pc.Mods.DamageReduct},
|
||||
}
|
||||
}
|
||||
|
||||
// duelResult is the two-orientation verdict. challengerMargin/challengedMargin
|
||||
// are aggregate remaining-HP fractions across both bouts (0..2 each); the higher
|
||||
// wins. decisive is true when one fighter won both orientations outright — pure
|
||||
// narration, the margin is what decides.
|
||||
type duelResult struct {
|
||||
challengerWins bool
|
||||
draw bool
|
||||
decisive bool
|
||||
challengerFrac float64
|
||||
challengedFrac float64
|
||||
}
|
||||
|
||||
func hpFraction(end, start int) float64 {
|
||||
if start <= 0 {
|
||||
return 0
|
||||
}
|
||||
if end < 0 {
|
||||
end = 0
|
||||
}
|
||||
return float64(end) / float64(start)
|
||||
}
|
||||
|
||||
// resolveDuel runs the bout both ways and decides on the aggregate HP margin.
|
||||
// Orientation 1 seats the challenger as the live fighter against the challenged
|
||||
// player's stat block; orientation 2 swaps them. Each fighter's margin sums the
|
||||
// HP fraction they kept as the live fighter in one bout and as the stat block in
|
||||
// the other, so the attacker's-kit advantage is applied to both fighters once
|
||||
// and cancels. rng is threaded for the seeded characterization/unit tests; nil
|
||||
// is production (package-global RNG).
|
||||
func resolveDuel(challenger, challenged Combatant, rng *rand.Rand) duelResult {
|
||||
r1 := simulateCombatWithRNG(challenger, synthDuelEnemy(challenged), defaultCombatPhases, rng)
|
||||
r2 := simulateCombatWithRNG(challenged, synthDuelEnemy(challenger), defaultCombatPhases, rng)
|
||||
|
||||
cFrac := hpFraction(r1.PlayerEndHP, r1.PlayerStartHP) + hpFraction(r2.EnemyEndHP, r2.EnemyStartHP)
|
||||
dFrac := hpFraction(r1.EnemyEndHP, r1.EnemyStartHP) + hpFraction(r2.PlayerEndHP, r2.PlayerStartHP)
|
||||
|
||||
res := duelResult{challengerFrac: cFrac, challengedFrac: dFrac}
|
||||
switch {
|
||||
case cFrac > dFrac:
|
||||
res.challengerWins = true
|
||||
case dFrac > cFrac:
|
||||
res.challengerWins = false
|
||||
default:
|
||||
res.draw = true
|
||||
}
|
||||
// Decisive = the winner also took both orientations outright.
|
||||
if !res.draw {
|
||||
if res.challengerWins {
|
||||
res.decisive = r1.PlayerWon && !r2.PlayerWon
|
||||
} else {
|
||||
res.decisive = r2.PlayerWon && !r1.PlayerWon
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ── Command surface ──────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleDuelCmd(ctx MessageContext, args string) error {
|
||||
args = strings.TrimSpace(args)
|
||||
fields := strings.Fields(args)
|
||||
if len(fields) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"⚔️ **Duels** — challenge another adventurer to a staked, no-death bout.\n"+
|
||||
"`!duel @user [stake]` — throw down · `!duel accept` / `!duel decline` · `!duel status`")
|
||||
}
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "accept":
|
||||
return p.acceptDuel(ctx)
|
||||
case "decline":
|
||||
return p.declineDuel(ctx)
|
||||
case "status":
|
||||
return p.duelStatusCmd(ctx)
|
||||
default:
|
||||
return p.issueDuel(ctx, fields)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) issueDuel(ctx MessageContext, fields []string) error {
|
||||
targetID, ok := p.ResolveUser(fields[0], ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve that user. Usage: `!duel @user [stake]`")
|
||||
}
|
||||
if targetID == ctx.Sender {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't duel yourself.")
|
||||
}
|
||||
|
||||
// Serialise the one-duel gate, balance check and escrow so two rapid `!duel`
|
||||
// from the same challenger can't both pass duelInvolves and double-debit.
|
||||
lock := p.advUserLock(ctx.Sender)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
challenger, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil || challenger == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer. Type `!adventure` to create one.")
|
||||
}
|
||||
lvl, reason := p.duelEligible(challenger)
|
||||
if reason != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason))
|
||||
}
|
||||
|
||||
challenged, err := loadAdvCharacter(targetID)
|
||||
if err != nil || challenged == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no adventurer to duel.", p.DisplayName(targetID)))
|
||||
}
|
||||
if _, reason := p.duelEligible(challenged); reason != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("%s can't duel right now (%s).", p.DisplayName(targetID), reason))
|
||||
}
|
||||
if challenged.BabysitActive {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("%s left word with the Babysitting Service — no duels while they're away.", p.DisplayName(targetID)))
|
||||
}
|
||||
|
||||
// One duel at a time per player, either side — the escrow makes overlaps a
|
||||
// double-commit of the same euros.
|
||||
if duelInvolves(ctx.Sender) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You already have a duel in flight. Resolve it first.")
|
||||
}
|
||||
if duelInvolves(targetID) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s already has a duel in flight.", p.DisplayName(targetID)))
|
||||
}
|
||||
|
||||
// Shared 7-day per-pair cooldown across both duel kinds (records table).
|
||||
if last := lastDuelBetween(ctx.Sender, targetID); !last.IsZero() && time.Since(last) < rivalSamePairCooldown {
|
||||
wait := rivalSamePairCooldown - time.Since(last)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You've dueled %s too recently. Try again %s.", p.DisplayName(targetID), formatDuration(wait)))
|
||||
}
|
||||
|
||||
stake, err := p.parseDuelStake(fields, lvl)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, err.Error())
|
||||
}
|
||||
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < stake {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You can't cover a €%d stake — you have €%d.", stake, int(bal)))
|
||||
}
|
||||
|
||||
// Escrow the challenger's stake now (debited to nowhere; the pot/winner split
|
||||
// redistributes it on resolution, and expiry/decline refunds it).
|
||||
if !p.euro.Debit(ctx.Sender, float64(stake), "duel_escrow") {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. Try again.")
|
||||
}
|
||||
|
||||
ch := &advDuelChallenge{
|
||||
ChallengeID: uuid.NewString(),
|
||||
ChallengerID: ctx.Sender,
|
||||
ChallengedID: targetID,
|
||||
Stake: stake,
|
||||
ExpiresAt: time.Now().UTC().Add(duelAcceptWindow),
|
||||
}
|
||||
insertDuelChallenge(ch)
|
||||
|
||||
p.SendDM(targetID, fmt.Sprintf(
|
||||
"⚔️ **%s challenges you to a duel!**\n\nStake: €%d each — winner takes 70%% of the pot.\n"+
|
||||
"No death, no hospital bill. Just bragging rights.\n\n`!duel accept` or `!duel decline` — you have 24 hours.",
|
||||
p.DisplayName(ctx.Sender), stake))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"⚔️ You throw down the gauntlet before **%s** for €%d. They have 24 hours to answer.",
|
||||
p.DisplayName(targetID), stake))
|
||||
}
|
||||
|
||||
// parseDuelStake reads the optional stake argument, defaulting to the cap for
|
||||
// the challenger's level and clamping into [duelMinStake, duelMaxStake].
|
||||
func (p *AdventurePlugin) parseDuelStake(fields []string, level int) (int, error) {
|
||||
maxStake := duelMaxStake(level)
|
||||
if len(fields) < 2 {
|
||||
return maxStake, nil
|
||||
}
|
||||
raw := strings.TrimPrefix(fields[1], "€")
|
||||
stake := 0
|
||||
if _, err := fmt.Sscanf(raw, "%d", &stake); err != nil || stake <= 0 {
|
||||
return 0, fmt.Errorf("that's not a valid stake. Usage: `!duel @user [stake]`")
|
||||
}
|
||||
if stake < duelMinStake {
|
||||
return 0, fmt.Errorf("minimum stake is €%d", duelMinStake)
|
||||
}
|
||||
if stake > maxStake {
|
||||
return 0, fmt.Errorf("your stake is capped at €%d (level %d × €%d)", maxStake, level, duelStakePerLvl)
|
||||
}
|
||||
return stake, nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) acceptDuel(ctx MessageContext) error {
|
||||
// Serialise a player's own accept so a double-submit can't debit twice.
|
||||
lock := p.advUserLock(ctx.Sender)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
ch := pendingDuelForChallenged(ctx.Sender)
|
||||
if ch == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to accept.")
|
||||
}
|
||||
// Cheap validation first — these leave the challenge open (the player can
|
||||
// revive/earn and retry, or decline to release the challenger's escrow).
|
||||
challenged, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil || challenged == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer.")
|
||||
}
|
||||
if _, reason := p.duelEligible(challenged); reason != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason))
|
||||
}
|
||||
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < ch.Stake {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You can't cover the €%d stake — you have €%d. `!duel decline` to back out.", ch.Stake, int(bal)))
|
||||
}
|
||||
|
||||
// The challenger may have died or dropped below the gate during the 24h
|
||||
// window. If so, cancel the duel: claim the row (so the ticker/decline can't
|
||||
// also refund) and hand the challenger their escrow back.
|
||||
challenger, cerr := loadAdvCharacter(ch.ChallengerID)
|
||||
if cerr != nil || challenger == nil {
|
||||
if claimDuelChallenge(ch.ChallengeID) {
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_gone")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Your challenger is no longer available. The duel is off.")
|
||||
}
|
||||
if _, reason := p.duelEligible(challenger); reason != "" {
|
||||
if claimDuelChallenge(ch.ChallengeID) {
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_ineligible")
|
||||
p.SendDM(ch.ChallengerID, fmt.Sprintf(
|
||||
"⚔️ Your duel with **%s** was called off — you can't fight right now. Your €%d stake is refunded.",
|
||||
p.DisplayName(ctx.Sender), ch.Stake))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("**%s** can't duel right now (%s). The challenge is off.", p.DisplayName(ch.ChallengerID), reason))
|
||||
}
|
||||
|
||||
// Build both fighters BEFORE debiting the challenged player, so the deep
|
||||
// sheet-loading / combat code (the panic-prone part) runs while nobody but
|
||||
// the challenger is committed. A build failure refunds only the challenger.
|
||||
challengerC, err1 := p.buildDuelist(ch.ChallengerID)
|
||||
challengedC, err2 := p.buildDuelist(ctx.Sender)
|
||||
if err1 != nil || err2 != nil {
|
||||
if claimDuelChallenge(ch.ChallengeID) {
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "The duel couldn't be staged. The challenger's stake is refunded.")
|
||||
}
|
||||
|
||||
// Commit: claim the row (exactly one path wins), then take the challenged
|
||||
// player's stake. From here both stakes are held and resolution is pure
|
||||
// arithmetic — no further build or DB read that could panic mid-payout.
|
||||
if !claimDuelChallenge(ch.ChallengeID) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "That duel is no longer available.")
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(ch.Stake), "duel_escrow") {
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. The duel is off.")
|
||||
}
|
||||
|
||||
return p.settleDuel(ctx, ch, challengerC, challengedC)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) declineDuel(ctx MessageContext) error {
|
||||
// Same lock as accept so a player's own accept/decline can't interleave; the
|
||||
// claim is the real guard, but the lock keeps the read→claim window tight.
|
||||
lock := p.advUserLock(ctx.Sender)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
ch := pendingDuelForChallenged(ctx.Sender)
|
||||
if ch == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.")
|
||||
}
|
||||
if !claimDuelChallenge(ch.ChallengeID) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.")
|
||||
}
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_declined")
|
||||
p.SendDM(ch.ChallengerID, fmt.Sprintf(
|
||||
"🛡️ **%s** declined your duel. Your €%d stake is refunded.", p.DisplayName(ctx.Sender), ch.Stake))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You decline the duel. No harm done.")
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) duelStatusCmd(ctx MessageContext) error {
|
||||
if ch := pendingDuelForChallenged(ctx.Sender); ch != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"⚔️ **%s** has challenged you for €%d. `!duel accept` / `!duel decline` (expires %s).",
|
||||
p.DisplayName(ch.ChallengerID), ch.Stake, formatDuration(time.Until(ch.ExpiresAt))))
|
||||
}
|
||||
// Any challenge we issued that's still pending?
|
||||
c := &advDuelChallenge{}
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
|
||||
FROM adventure_duel_challenges
|
||||
WHERE challenger_id = ? AND expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY created_at DESC LIMIT 1`, string(ctx.Sender)).Scan(
|
||||
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
|
||||
if err == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"⚔️ You've challenged **%s** for €%d. Waiting on their answer (expires %s).",
|
||||
p.DisplayName(c.ChallengedID), c.Stake, formatDuration(time.Until(c.ExpiresAt))))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no duels in flight. `!duel @user [stake]` to start one.")
|
||||
}
|
||||
|
||||
// ── Resolution ───────────────────────────────────────────────────────────────
|
||||
|
||||
// settleDuel resolves the two-orientation bout, splits the pooled 2×stake,
|
||||
// records the result, and DMs both players. By the time it runs the challenge
|
||||
// row is claimed, both fighters are built, and both stakes are held — so from
|
||||
// here it is pure arithmetic and message sends, nothing that can panic and
|
||||
// strand the pool. On an exact HP-margin tie both stakes are refunded.
|
||||
func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, challengerC, challengedC Combatant) error {
|
||||
res := resolveDuel(challengerC, challengedC, nil)
|
||||
|
||||
if res.draw {
|
||||
// Exact HP-margin tie: refund both, record nothing, no cooldown burned.
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_draw")
|
||||
p.euro.Credit(ch.ChallengedID, float64(ch.Stake), "duel_refund_draw")
|
||||
draw := fmt.Sprintf("⚔️ **%s** and **%s** fought to a standstill. Both stakes refunded.",
|
||||
p.DisplayName(ch.ChallengerID), p.DisplayName(ch.ChallengedID))
|
||||
p.SendDM(ch.ChallengerID, draw)
|
||||
p.SendDM(ch.ChallengedID, draw)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, draw)
|
||||
}
|
||||
|
||||
winnerID, loserID := ch.ChallengedID, ch.ChallengerID
|
||||
if res.challengerWins {
|
||||
winnerID, loserID = ch.ChallengerID, ch.ChallengedID
|
||||
}
|
||||
|
||||
winnerShare, potShare := duelPayout(ch.Stake)
|
||||
p.euro.Credit(winnerID, float64(winnerShare), "duel_win")
|
||||
if potShare > 0 {
|
||||
communityPotAdd(potShare)
|
||||
// The rake came out of a pool both funded; attribute it half each.
|
||||
trackTaxPaid(winnerID, potShare/2)
|
||||
trackTaxPaid(loserID, potShare-potShare/2)
|
||||
}
|
||||
|
||||
upsertRivalRecord(winnerID, loserID, true)
|
||||
upsertRivalRecord(loserID, winnerID, false)
|
||||
|
||||
// One BULLETIN dispatch per settled duel (from the winner's side, so it
|
||||
// fires once). Character names only; no-op unless the seam is enabled.
|
||||
if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" {
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("rival:%d", ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), ts),
|
||||
EventType: "rival_result",
|
||||
Tier: "bulletin",
|
||||
Subject: wn,
|
||||
Opponent: ln,
|
||||
Outcome: "won",
|
||||
OccurredAt: ts,
|
||||
}, winnerID, loserID)
|
||||
}
|
||||
|
||||
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceDuel(ctx MessageContext, winnerID, loserID id.UserID, stake, winnerShare, potShare int, decisive bool) {
|
||||
winName, loseName := p.DisplayName(winnerID), p.DisplayName(loserID)
|
||||
flair := pickDuelFlavor(duelWinFlavor)
|
||||
if decisive {
|
||||
flair = pickDuelFlavor(duelDecisiveFlavor)
|
||||
}
|
||||
|
||||
p.SendDM(winnerID, fmt.Sprintf(
|
||||
"⚔️ **You beat %s!**\n\n*%s*\n\nYou take €%d (net +€%d). €%d went to the community pot.",
|
||||
loseName, flair, winnerShare, winnerShare-stake, potShare))
|
||||
p.SendDM(loserID, fmt.Sprintf(
|
||||
"⚔️ **%s bested you.**\n\n*%s*\n\nYou're down €%d — but you walk away, no worse for wear.",
|
||||
winName, pickDuelFlavor(duelLossFlavor), stake))
|
||||
|
||||
// Broadcast the result to the games room for bragging rights — unless the
|
||||
// duel was accepted there, in which case the SendReply below already covers
|
||||
// it and a second line would just be noise.
|
||||
if gr := gamesRoom(); gr != "" && gr != ctx.RoomID {
|
||||
verb := "defeated"
|
||||
if decisive {
|
||||
verb = "decisively defeated"
|
||||
}
|
||||
p.SendMessage(gr, fmt.Sprintf("⚔️ **%s** %s **%s** in a duel for €%d.", winName, verb, loseName, stake))
|
||||
}
|
||||
// Confirm in the room the challenge was accepted from too.
|
||||
p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("⚔️ **%s** wins the duel, taking €%d.", winName, winnerShare))
|
||||
}
|
||||
|
||||
// expireDuelChallenges refunds and clears any challenge whose 24h window lapsed.
|
||||
// Rides the 1-minute event ticker (no new goroutine); only the challenger was
|
||||
// escrowed, so only they are refunded.
|
||||
func (p *AdventurePlugin) expireDuelChallenges() {
|
||||
for _, ch := range loadExpiredDuelChallenges() {
|
||||
// Claim before refunding: if an accept/decline already took this row at
|
||||
// the 24h boundary, we must not also refund the challenger.
|
||||
if !claimDuelChallenge(ch.ChallengeID) {
|
||||
continue
|
||||
}
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_expired")
|
||||
p.SendDM(ch.ChallengerID, fmt.Sprintf(
|
||||
"⌛ **%s** never answered your duel. Your €%d stake is refunded.",
|
||||
p.DisplayName(ch.ChallengedID), ch.Stake))
|
||||
}
|
||||
}
|
||||
|
||||
func duelIneligibleSelf(reason string) string {
|
||||
switch reason {
|
||||
case "dead":
|
||||
return "You can't duel while dead. Visit `!hospital` first."
|
||||
case "no adventurer":
|
||||
return "You have no adventurer. Type `!adventure` to create one."
|
||||
default:
|
||||
return fmt.Sprintf("You need to be at least level %d to duel.", duelMinLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor ───────────────────────────────────────────────────────────────────
|
||||
|
||||
var duelWinFlavor = []string{
|
||||
"Steel rang, and the crowd knew its champion.",
|
||||
"A clean finish — you left them nothing to answer with.",
|
||||
"They fought well. You fought better.",
|
||||
}
|
||||
|
||||
var duelDecisiveFlavor = []string{
|
||||
"It was over before it began. Utterly one-sided.",
|
||||
"You never gave them a foothold. A rout.",
|
||||
"The arena barely had time to hush.",
|
||||
}
|
||||
|
||||
var duelLossFlavor = []string{
|
||||
"You'll want that one back. Next time.",
|
||||
"A hard lesson — but only your pride is bleeding.",
|
||||
"Close, in places. Not close enough.",
|
||||
}
|
||||
|
||||
func pickDuelFlavor(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newDuelTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db.Close()
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// duelSeed builds a deterministic RNG so the two-orientation resolver is
|
||||
// reproducible across runs — the production path passes nil (global RNG).
|
||||
func duelSeed(i uint64) *rand.Rand { return rand.New(rand.NewPCG(0x1d4e11, i)) }
|
||||
|
||||
func TestDuelPayout(t *testing.T) {
|
||||
// Both escrow 2000 → pool 4000 → winner 70% (2800), pot 30% (1200).
|
||||
w, pot := duelPayout(2000)
|
||||
if w != 2800 || pot != 1200 {
|
||||
t.Fatalf("duelPayout(2000) = (%d, %d), want (2800, 1200)", w, pot)
|
||||
}
|
||||
// The pot always gets exactly what the winner doesn't; nothing is minted
|
||||
// or lost against the pooled 2×stake.
|
||||
for _, stake := range []int{100, 137, 999, 6000} {
|
||||
w, pot := duelPayout(stake)
|
||||
if w+pot != stake*2 {
|
||||
t.Errorf("duelPayout(%d): winner+pot = %d, want pool %d", stake, w+pot, stake*2)
|
||||
}
|
||||
if w <= pot {
|
||||
t.Errorf("duelPayout(%d): winner %d should exceed pot %d", stake, w, pot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuelMaxStake(t *testing.T) {
|
||||
if got := duelMaxStake(5); got != 2500 {
|
||||
t.Fatalf("duelMaxStake(5) = %d, want 2500", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuelPerHitDamage(t *testing.T) {
|
||||
// 1d8 (avg 4.5) + ability mod 3 = 7.5 base, + Divine Strike 2 = 9.5,
|
||||
// × (1 + 0.25 damage bonus) = 11.875.
|
||||
pc := Combatant{
|
||||
Stats: CombatStats{
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
|
||||
AbilityModForDamage: 3,
|
||||
},
|
||||
Mods: CombatModifiers{DivineStrikePerHit: 2, DamageBonus: 0.25},
|
||||
}
|
||||
got := duelPerHitDamage(pc)
|
||||
if math.Abs(got-11.875) > 1e-9 {
|
||||
t.Fatalf("duelPerHitDamage = %v, want 11.875", got)
|
||||
}
|
||||
|
||||
// Weaponless build falls back to the flat Attack stat.
|
||||
bare := Combatant{Stats: CombatStats{Attack: 9}}
|
||||
if got := duelPerHitDamage(bare); got != 9 {
|
||||
t.Fatalf("weaponless duelPerHitDamage = %v, want 9", got)
|
||||
}
|
||||
|
||||
// A build that computes to <1 is floored to 1 (a fight always deals damage).
|
||||
weak := Combatant{Stats: CombatStats{Attack: 0}}
|
||||
if got := duelPerHitDamage(weak); got != 1 {
|
||||
t.Fatalf("floored duelPerHitDamage = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthDuelEnemy(t *testing.T) {
|
||||
pc := Combatant{
|
||||
Name: "Aria",
|
||||
IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 123,
|
||||
AC: 17,
|
||||
AttackBonus: 8,
|
||||
Defense: 5,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
|
||||
AbilityModForDamage: 3,
|
||||
},
|
||||
Mods: CombatModifiers{ExtraAttacks: 1}, // two swings/round
|
||||
}
|
||||
e := synthDuelEnemy(pc)
|
||||
|
||||
if e.IsPlayer {
|
||||
t.Error("synthesised enemy must not be flagged IsPlayer")
|
||||
}
|
||||
if e.Ability != nil {
|
||||
t.Error("v1 synth enemy carries no MonsterAbility")
|
||||
}
|
||||
// HP / AC / to-hit carry over verbatim: to-hit stays faithful both ways.
|
||||
if e.Stats.MaxHP != 123 || e.Stats.AC != 17 || e.Stats.AttackBonus != 8 {
|
||||
t.Errorf("stat carryover wrong: got HP=%d AC=%d AB=%d", e.Stats.MaxHP, e.Stats.AC, e.Stats.AttackBonus)
|
||||
}
|
||||
// Damage: per-hit (7.5) × swings (2) folded into one flat Attack = 15.
|
||||
if e.Stats.Attack != 15 {
|
||||
t.Errorf("folded Attack = %d, want 15 (7.5 × 2 swings)", e.Stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDuel_StrongerWinsRegardlessOfRole pins that a clearly superior
|
||||
// build wins whether it issues or receives the challenge — the two-orientation
|
||||
// resolver must not hand the win to the attacker seat.
|
||||
func TestResolveDuel_StrongerWinsRegardlessOfRole(t *testing.T) {
|
||||
strong := Combatant{
|
||||
Name: "Champion", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 220, AC: 19, AttackBonus: 12,
|
||||
Weapon: &WeaponProfile{DamageCount: 2, DamageSides: 8}, AbilityModForDamage: 5, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
weak := Combatant{
|
||||
Name: "Novice", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 45, AC: 11, AttackBonus: 3,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 4}, AbilityModForDamage: 0, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
|
||||
for i := uint64(0); i < 50; i++ {
|
||||
if r := resolveDuel(strong, weak, duelSeed(i)); r.draw || !r.challengerWins {
|
||||
t.Fatalf("seed %d: strong-as-challenger should win, got %+v", i, r)
|
||||
}
|
||||
if r := resolveDuel(weak, strong, duelSeed(i)); r.draw || r.challengerWins {
|
||||
t.Fatalf("seed %d: strong-as-challenged should win, got %+v", i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDuel_IdenticalFightersAreUnbiased is the fairness guarantee that
|
||||
// justifies running both orientations: with two identical builds neither the
|
||||
// challenger nor the challenged seat may have a systematic edge. A single-
|
||||
// orientation model (attacker uses full kit, defender is a stat block) would
|
||||
// skew hard toward the challenger; the both-orientations margin cancels it.
|
||||
func TestResolveDuel_IdenticalFightersAreUnbiased(t *testing.T) {
|
||||
fighter := Combatant{
|
||||
Name: "Mirror", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 110, AC: 15, AttackBonus: 7,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 3, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
|
||||
const n = 1200
|
||||
challengerWins, draws := 0, 0
|
||||
for i := uint64(0); i < n; i++ {
|
||||
r := resolveDuel(fighter, fighter, duelSeed(i))
|
||||
switch {
|
||||
case r.draw:
|
||||
draws++
|
||||
case r.challengerWins:
|
||||
challengerWins++
|
||||
}
|
||||
}
|
||||
decided := n - draws
|
||||
if decided == 0 {
|
||||
t.Fatal("all identical-fighter duels drew — resolver isn't discriminating")
|
||||
}
|
||||
// Unbiased by construction (challenger wins iff D1 > D2 for iid D); allow a
|
||||
// generous ±10% band so this stays a bias check, not a flaky coin-count.
|
||||
rate := float64(challengerWins) / float64(decided)
|
||||
if rate < 0.40 || rate > 0.60 {
|
||||
t.Fatalf("challenger win rate %.3f over %d decided duels — systematic bias (want ~0.5)", rate, decided)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDuelChallenge pins the serialization primitive that guards every
|
||||
// euro path: exactly one caller may claim a challenge, so accept/decline/expire
|
||||
// can never both move money for the same row.
|
||||
func TestClaimDuelChallenge(t *testing.T) {
|
||||
newDuelTestDB(t)
|
||||
ch := &advDuelChallenge{
|
||||
ChallengeID: "duel-1",
|
||||
ChallengerID: id.UserID("@a:x"),
|
||||
ChallengedID: id.UserID("@b:x"),
|
||||
Stake: 1000,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
insertDuelChallenge(ch)
|
||||
|
||||
if pendingDuelForChallenged(id.UserID("@b:x")) == nil {
|
||||
t.Fatal("challenge should be pending for the challenged player before any claim")
|
||||
}
|
||||
if !duelInvolves(id.UserID("@a:x")) || !duelInvolves(id.UserID("@b:x")) {
|
||||
t.Fatal("duelInvolves should see both parties while the challenge is live")
|
||||
}
|
||||
|
||||
if !claimDuelChallenge("duel-1") {
|
||||
t.Fatal("first claim must succeed")
|
||||
}
|
||||
if claimDuelChallenge("duel-1") {
|
||||
t.Fatal("second claim must fail — the row is gone, so no double refund/payout")
|
||||
}
|
||||
if pendingDuelForChallenged(id.UserID("@b:x")) != nil {
|
||||
t.Fatal("claimed challenge must no longer be pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExpiredDuelChallenges(t *testing.T) {
|
||||
newDuelTestDB(t)
|
||||
insertDuelChallenge(&advDuelChallenge{
|
||||
ChallengeID: "live", ChallengerID: "@a:x", ChallengedID: "@b:x",
|
||||
Stake: 500, ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
insertDuelChallenge(&advDuelChallenge{
|
||||
ChallengeID: "stale", ChallengerID: "@c:x", ChallengedID: "@d:x",
|
||||
Stake: 500, ExpiresAt: time.Now().UTC().Add(-time.Hour),
|
||||
})
|
||||
got := loadExpiredDuelChallenges()
|
||||
if len(got) != 1 || got[0].ChallengeID != "stale" {
|
||||
t.Fatalf("loadExpiredDuelChallenges = %+v, want only the stale one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthDuelEnemy_PortsDefenseAndProcs pins the fidelity carry-overs added
|
||||
// after review: a tank's DamageReduct rides onto the synth enemy, and companion
|
||||
// procs fold their expected damage into its round Attack.
|
||||
func TestSynthDuelEnemy_PortsDefenseAndProcs(t *testing.T) {
|
||||
pc := Combatant{
|
||||
Stats: CombatStats{
|
||||
MaxHP: 100, AC: 15, AttackBonus: 6,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 2, // 6.5/hit
|
||||
},
|
||||
Mods: CombatModifiers{
|
||||
DamageReduct: 0.6, // Barbarian-style mitigation
|
||||
SpiritWeaponProc: 0.5, SpiritWeaponDmg: 4, // expected 0.5×(4+3)=3.5/round
|
||||
},
|
||||
}
|
||||
e := synthDuelEnemy(pc)
|
||||
if e.Mods.DamageReduct != 0.6 {
|
||||
t.Errorf("DamageReduct not ported: got %v, want 0.6", e.Mods.DamageReduct)
|
||||
}
|
||||
// One swing (6.5) + proc expectation (3.5) = 10 → rounded 10.
|
||||
if e.Stats.Attack != 10 {
|
||||
t.Errorf("folded Attack = %d, want 10 (6.5 weapon + 3.5 proc)", e.Stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuelStake(t *testing.T) {
|
||||
p := &AdventurePlugin{}
|
||||
const level = 5 // cap = 2500
|
||||
|
||||
// No stake → default to the cap.
|
||||
if got, err := p.parseDuelStake([]string{"@u"}, level); err != nil || got != 2500 {
|
||||
t.Fatalf("default stake = (%d, %v), want (2500, nil)", got, err)
|
||||
}
|
||||
// Explicit in-band stake, with and without the € prefix.
|
||||
if got, err := p.parseDuelStake([]string{"@u", "€1500"}, level); err != nil || got != 1500 {
|
||||
t.Fatalf("€1500 stake = (%d, %v), want (1500, nil)", got, err)
|
||||
}
|
||||
// Over the cap is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "9999"}, level); err == nil {
|
||||
t.Error("stake above cap should error")
|
||||
}
|
||||
// Below the floor is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "10"}, level); err == nil {
|
||||
t.Error("stake below the minimum should error")
|
||||
}
|
||||
// Garbage is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "lots"}, level); err == nil {
|
||||
t.Error("non-numeric stake should error")
|
||||
}
|
||||
}
|
||||
@@ -23,78 +23,18 @@ type advActiveEvent struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// ── 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
|
||||
)
|
||||
// ── 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.
|
||||
|
||||
var (
|
||||
advEventFiredMu sync.Mutex
|
||||
advEventFired map[string]bool // userID -> an event already fired today
|
||||
advEventFiredDay string // "2006-01-02" the map belongs to
|
||||
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
|
||||
)
|
||||
|
||||
// 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() {
|
||||
@@ -106,53 +46,90 @@ 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()
|
||||
|
||||
// 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()
|
||||
advEventScheduleMu.Lock()
|
||||
if advEventScheduleDay != dateKey {
|
||||
advEventSchedule = make(map[string]int)
|
||||
advEventRolled = make(map[string]bool)
|
||||
advEventScheduleDay = dateKey
|
||||
}
|
||||
|
||||
// Reclaim invites nobody answered (N3/P6b). Every read already
|
||||
// filters on the TTL; this just stops the rows accumulating.
|
||||
purgeExpiredInvites()
|
||||
// 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)
|
||||
}
|
||||
|
||||
// World boss (N6/C3): auto-spawn the monthly Siege on the 1st and
|
||||
// resolve one whose 72h window has lapsed. Own dedup inside.
|
||||
p.worldBossTick()
|
||||
// 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()
|
||||
|
||||
// Duels (N6/C2): refund and clear any challenge whose 24h accept
|
||||
// window lapsed.
|
||||
p.expireDuelChallenges()
|
||||
for _, uid := range toRoll {
|
||||
p.tryTriggerEvent(uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
// Load character — must be alive and have acted today
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil || !char.Alive {
|
||||
return false
|
||||
if err != nil || char == nil || !char.Alive || !char.HasActedToday() {
|
||||
return
|
||||
}
|
||||
|
||||
// Already has an active event?
|
||||
active, _ := loadAdvActiveEvent(userID)
|
||||
if active != nil {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// Mid-fight: a turn-based session locks the run. Don't drop a random
|
||||
// overworld event into a live fight — the player can't act on it without
|
||||
// finishing the fight first, and the trigger DM talks over the combat feed.
|
||||
if hasActiveCombatSession(userID) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// 0.5% chance
|
||||
if rand.Float64() >= 0.005 {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine today's activity for filtering
|
||||
@@ -161,7 +138,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool {
|
||||
// Pick an event
|
||||
event := advPickRandomEvent(userID, activityType)
|
||||
if event == nil {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// Insert into DB
|
||||
@@ -170,7 +147,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool {
|
||||
eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err)
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
|
||||
@@ -193,7 +170,6 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool {
|
||||
})
|
||||
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleEventRespond processes `!adventure respond`.
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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,22 +49,6 @@ 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.",
|
||||
@@ -79,3 +63,4 @@ 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_",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The Hollow King campaign (N5/D1). A light serialized story threaded through
|
||||
// the zones by way of collectible journal pages. "The Hollow King" is already
|
||||
// the Forest Shadows (T2) boss; this frames him as a realm-spanning antagonist
|
||||
// whose fragments turn up wherever players fight elites and open secret rooms.
|
||||
//
|
||||
// The fragments are in-world found artifacts — journal entries, torn letters,
|
||||
// stone inscriptions — not TwinBee's voice. TwinBee's own reactions (D1b) obey
|
||||
// the first-person voice rules; this text does not.
|
||||
|
||||
type journalPage struct {
|
||||
Title string
|
||||
Text string
|
||||
}
|
||||
|
||||
// journalPages is the ordered campaign. Reading the discovered pages top to
|
||||
// bottom tells the fall of a kingdom to the thing its king became. Order is the
|
||||
// story order; players find them out of sequence, which is why the viewer marks
|
||||
// gaps.
|
||||
var journalPages = []journalPage{
|
||||
{"The Long Winter", "A crown is only a circle of gold until a man decides never to take it off. Ours decided in a winter that would not end, and the frost took its cue from him."},
|
||||
{"The Last Physician", "The court healers were sent home one by one, each promising the king had years left. The last was not sent home. We heard him thank the king for the honour."},
|
||||
{"On Shadows", "He stopped casting a shadow before he stopped casting a reflection. The steward struck both from the list of things a servant may notice aloud."},
|
||||
{"The Quiet Ledger", "Grain still left the granaries; no mouths were fed. I stopped auditing the difference the night the number began to feel like a name."},
|
||||
{"A Bargain Overheard", "Through the chapel door: the king's voice, and a second that used his own words a breath before he did. Only one of them was asking."},
|
||||
{"The Hollowing", "They call it a coronation in the records. Those of us who carried the braziers call it what it was. A king was emptied so a crown could keep wearing him."},
|
||||
{"The First Knights", "His honour guard did not die. That was the mercy offered and the price paid. They stand at the old gate yet, and they still salute a throne no living thing sits on."},
|
||||
{"Letters Home, Unsent", "\"Tell mother the pay is good and the work is guarding.\" The satchel held forty such letters, every hand different, every promise the same, none of them sent."},
|
||||
{"The Map Redrawn", "The kingdom did not fall so much as come apart at the seams — a warren here, a drowned temple there, each piece keeping a splinter of him like a tooth in a wound."},
|
||||
{"Root and Rot", "The forest north of the manor grew wrong and grew fast. The woodsmen say the trees lean toward the ruin at dusk. The woodsmen no longer go at dusk."},
|
||||
{"The Warren Below", "Even the goblins gave the deep tunnels to him and asked nothing back. When a scavenger yields ground for free, ask what it saw down there."},
|
||||
{"Water That Remembers", "The temple sank in a single night with the bells still ringing. Divers say the bells ring still, slow, as if something below is counting."},
|
||||
{"The Manor's Long Guest", "Blackspire changed hands nine times in a decade. Every deed names a different owner. Every household names the same tenant, and none will write it down."},
|
||||
{"The Forge Unbanked", "The underforge keeps a heat with no fuel and no smith. What it makes, no one has seen leave. What it makes, we are told, was promised elsewhere long ago."},
|
||||
{"Descent", "The deep roads were a trade route once. Now they are a throat. Everything the surface loses is swallowed the same direction, and the direction has a door at the end."},
|
||||
{"The Bright Country", "Past the crossing the colours are too kind and the days too long. It is the most beautiful place I have run from. He is patient there; he can afford to be."},
|
||||
{"What the Dragon Keeps", "The wyrm hoards more than gold. Deep in the lair, behind the coin, a single crown sits on no head and is guarded better than the hoard."},
|
||||
{"The Portal's Arithmetic", "The abyss gate opens outward. Everyone assumes a door lets things in. This one was built by someone who only ever intended to leave through it."},
|
||||
{"The Regent's Confession", "I ruled in his name for thirty years and never once saw him rule. I signed what the crown wanted signed. I am writing this so that one honest page exists."},
|
||||
{"The Names of the Guard", "I have set down every knight's name here so that when this is read, they are grieved as men and not feared as things. It is the only rescue left to attempt."},
|
||||
{"The Flaw in the Bargain", "The second voice took the king's life and his death both — and a thing that cannot die also cannot be finished. He is not immortal. He is unpaid, and waiting to collect."},
|
||||
{"How to Call Him", "He answers only where his fragments gather and only to one who has gathered them. Do not do this to avenge us. Do it to end the account. Come with the whole ledger or do not come."},
|
||||
{"The Empty Throne", "I have seen the seat at the heart of it all. It is not empty. It is occupied by the shape of a man who left, kept warm against his return."},
|
||||
{"Last Page", "If you are reading in order, you have walked the ruin of everything he emptied to stay. One page is missing from every telling — the one you write by going in. Bring a light. He hates the light. It is the one thing he could never hollow out."},
|
||||
}
|
||||
|
||||
// journalTotalPages is the campaign length; the drop/viewer/finale all read it
|
||||
// so the story can grow by appending to journalPages alone.
|
||||
var journalTotalPages = len(journalPages)
|
||||
|
||||
// journalPageDropChance is the per-elite-kill probability that a page turns up.
|
||||
// Deliberately modest: 24 pages is a long-horizon collection, and secret rooms
|
||||
// (D4) grant pages on top of this. Tunable.
|
||||
const journalPageDropChance = 0.22
|
||||
|
||||
// setJournalPageBit is the in-memory twin of grantJournalPageDB's bitwise OR —
|
||||
// the single definition of "page N lives in bit N-1". Out-of-range pages are a
|
||||
// no-op.
|
||||
func setJournalPageBit(mask int64, page int) int64 {
|
||||
if page < 1 || page > 63 {
|
||||
return mask
|
||||
}
|
||||
return mask | (int64(1) << (page - 1))
|
||||
}
|
||||
|
||||
func journalPageFound(mask int64, page int) bool {
|
||||
if page < 1 || page > 63 {
|
||||
return false
|
||||
}
|
||||
return mask&(int64(1)<<(page-1)) != 0
|
||||
}
|
||||
|
||||
func journalPageCount(mask int64) int {
|
||||
n := 0
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if journalPageFound(mask, i) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func journalComplete(mask int64) bool {
|
||||
return journalPageCount(mask) >= journalTotalPages
|
||||
}
|
||||
|
||||
// pickUnfoundJournalPage returns a random not-yet-found page number, or 0 when
|
||||
// the campaign is already complete.
|
||||
func pickUnfoundJournalPage(mask int64, rng *rand.Rand) int {
|
||||
var missing []int
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if !journalPageFound(mask, i) {
|
||||
missing = append(missing, i)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return 0
|
||||
}
|
||||
return missing[rngIntN(rng, len(missing))]
|
||||
}
|
||||
|
||||
// maybeDropJournalPage rolls a page reward for an elite kill or secret room and,
|
||||
// on a hit, grants a random unfound page and returns its narration line. Empty
|
||||
// string means no drop (missed the roll, DB error, or campaign already
|
||||
// complete). The roll draws from the same RNG the surrounding loot rolls use
|
||||
// and never touches SimulateCombat's stream, so the combat golden is unmoved.
|
||||
func (p *AdventurePlugin) maybeDropJournalPage(userID id.UserID, rng *rand.Rand) string {
|
||||
if rngFloat(rng) >= journalPageDropChance {
|
||||
return ""
|
||||
}
|
||||
return p.grantJournalPage(userID, rng)
|
||||
}
|
||||
|
||||
// grantJournalPage grants a random unfound page unconditionally (used by secret
|
||||
// rooms, which award a page for certain). Returns "" when already complete or on
|
||||
// error.
|
||||
func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) string {
|
||||
mask, err := loadJournalPages(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
page := pickUnfoundJournalPage(mask, rng)
|
||||
if page == 0 {
|
||||
return ""
|
||||
}
|
||||
if err := grantJournalPageDB(userID, page); err != nil {
|
||||
return ""
|
||||
}
|
||||
// If the page turned up mid-expedition, drop a log beat so the end-of-day
|
||||
// digest can have TwinBee react to it. No expedition (legacy !zone, or a
|
||||
// secret room opened outside a run) simply means no digest to react in.
|
||||
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "journal",
|
||||
journalPages[page-1].Title, fmt.Sprintf("page %d", page))
|
||||
}
|
||||
return fmt.Sprintf("📖 A torn journal page — _%s_ (page %d of %d). See `!adventure journal`.",
|
||||
journalPages[page-1].Title, page, journalTotalPages)
|
||||
}
|
||||
|
||||
// --- N5/D4 secret content pass ---------------------------------------------
|
||||
//
|
||||
// Every NodeKindSecret room resolves as a no-combat treasure cache (see
|
||||
// resolveSecretRoom in dnd_zone_combat.go). Finding it — via the perception /
|
||||
// stat check on the fork, or a cross-zone key — is the reward: a guaranteed
|
||||
// journal page, a LootBias-weighted treasure roll, and a guaranteed consumable
|
||||
// cache. The flavor and the key catalog live here with the rest of the
|
||||
// campaign; the resolution mechanics live next to the other room resolvers.
|
||||
|
||||
// secretRoomCacheCount is how many zone-tier consumables a secret room always
|
||||
// hands over, so the room pays out something visible even for a player who has
|
||||
// every page and misses the treasure roll.
|
||||
const secretRoomCacheCount = 2
|
||||
|
||||
// crossZoneKey pairs a source secret room with the key item it grants. The key
|
||||
// is a plain inventory item; a LockKey edge in the destination zone matches its
|
||||
// lower-cased Name against lock_data.key_id (see evaluateEdgeLock). Keys persist
|
||||
// in inventory, so the unlock is permanent once earned.
|
||||
type crossZoneKey struct {
|
||||
item AdvItem
|
||||
unlocksIn ZoneID // destination zone, for the grant narration
|
||||
unlockHint string // what the key opens, surfaced when it's granted
|
||||
}
|
||||
|
||||
// secretRoomKeys — the two cross-zone keys (N5/D4). A Sunken Temple sigil opens
|
||||
// a sealed reliquary in Manor Blackspire; an Underforge seal opens a vault deep
|
||||
// in the Underdark throne approach. Keyed by the source secret's node ID.
|
||||
var secretRoomKeys = map[string]crossZoneKey{
|
||||
"sunken_temple.coral_reliquary": {
|
||||
item: AdvItem{Name: "Sunken Sigil", Type: "key", Tier: 2},
|
||||
unlocksIn: ZoneManorBlackspire,
|
||||
unlockHint: "a drowned god's mark — Blackspire has a door that remembers it",
|
||||
},
|
||||
"underforge.forge_vault": {
|
||||
item: AdvItem{Name: "Underforge Seal", Type: "key", Tier: 3},
|
||||
unlocksIn: ZoneUnderdark,
|
||||
unlockHint: "the forge's own brand — something in the deep roads was promised its work",
|
||||
},
|
||||
}
|
||||
|
||||
// grantSecretRoomKey hands over the cross-zone key a secret room carries, if it
|
||||
// carries one and the player isn't already holding it. Idempotent: re-finding
|
||||
// the room on a later run won't stack duplicate keys. Returns the narration line
|
||||
// (empty when the room grants no key, the player already has it, or on error).
|
||||
func (p *AdventurePlugin) grantSecretRoomKey(userID id.UserID, node ZoneNode) string {
|
||||
key, ok := secretRoomKeys[node.NodeID]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
want := strings.ToLower(key.item.Name)
|
||||
for _, it := range items {
|
||||
if strings.ToLower(it.Name) == want {
|
||||
return "" // already earned — don't stack it
|
||||
}
|
||||
}
|
||||
if err := addAdvInventoryItem(userID, key.item); err != nil {
|
||||
slog.Error("secret room: grant key", "user", userID, "key", key.item.Name, "err", err)
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("🗝 **%s** — %s.", key.item.Name, key.unlockHint)
|
||||
}
|
||||
|
||||
// secretRoomDiscovery is the bespoke in-world discovery line for a secret room,
|
||||
// keyed by node ID. Not TwinBee's voice — the same in-world register the journal
|
||||
// pages use. Nodes without an entry fall back to a generic line built from the
|
||||
// node label.
|
||||
var secretRoomDiscovery = map[string]string{
|
||||
"crypt_valdris.secret_chamber": "🔍 **A sealed chamber.** The other grave-robbers pried at the wrong wall. This one gives — Valdris kept something back even from his own tomb.",
|
||||
"forest_shadows.sapling_shrine": "🔍 **The Sapling Shrine.** A ring of young antlers grown from the dirt, and something votive left at the centre — an offering the King's shell never came back to claim.",
|
||||
"sunken_temple.coral_reliquary": "🔍 **The Coral Reliquary.** Behind a lattice of living coral, a niche the flood sealed rather than drowned. What the temple hid, it hid well.",
|
||||
"manor_blackspire.hidden_oratory": "🔍 **The Hidden Oratory.** A chapel with no door in the floor plan, kept for a tenant the deeds refuse to name.",
|
||||
"manor_blackspire.sealed_reliquary": "🔍 **The Sealed Reliquary.** The drowned god's sigil bites into the lock and turns. Blackspire kept a piece of the temple's secret behind a door only the temple could open.",
|
||||
"underforge.forge_vault": "🔍 **The Forge Vault.** A strongroom off the unbanked heat, its ledger of commissions all addressed elsewhere and long overdue.",
|
||||
"underdark.lost_reliquary": "🔍 **A lost reliquary.** A shrine the deep roads swallowed whole, still lit, still waiting on a procession that stopped coming a long age ago.",
|
||||
"underdark.sealed_forge_vault": "🔍 **The Sealed Vault.** The Underforge's brand fits the seam, and the deep-road door gives up what the forge sent ahead — payment on the old account.",
|
||||
"feywild_crossing.illusion_garden": "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
|
||||
"dragons_lair.hoard_pillar": "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
|
||||
"abyss_portal.reality_seam": "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",
|
||||
}
|
||||
|
||||
// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
|
||||
// falling back to a label-driven generic when the node has no bespoke entry.
|
||||
func secretRoomDiscoveryLine(node ZoneNode) string {
|
||||
if line, ok := secretRoomDiscovery[node.NodeID]; ok {
|
||||
return line
|
||||
}
|
||||
label := node.Label
|
||||
if label == "" {
|
||||
label = "a hidden room"
|
||||
}
|
||||
return fmt.Sprintf("🔍 **%s** — a room the others walked past. Something was left here for whoever looked closer.", label)
|
||||
}
|
||||
|
||||
// bossEpilogues ties each zone boss's death to the Hollow King arc: a 2-3
|
||||
// sentence capstone appended to the boss-down moment. Forest of Shadows is the
|
||||
// King himself — but what falls there is a shell he shed, which is why the arc
|
||||
// (and the finale) outlives it. In-world narration, not TwinBee's voice.
|
||||
var bossEpilogues = map[ZoneID]string{
|
||||
ZoneGoblinWarrens: "Grol dies clutching a coin no goblin minted — a king's face worn smooth by handling. Whatever paid the warren to give up its deep tunnels, it paid in a currency older than these hills.",
|
||||
ZoneCryptValdris: "Valdris tried to cheat the grave and managed only to furnish it. In his last rattle he says a name that isn't his — _hollow, hollow_ — as if warning you of a colleague who did it better.",
|
||||
ZoneForestShadows: "The Hollow King falls without weight, a coat slipped from its peg — and the woods do not go quiet. What you felled here was a thing he shed, not the thing he is. Somewhere, the account he owes goes on accruing.",
|
||||
ZoneSunkenTemple: "The Aboleth's dream breaks and the drowned bells still at last. In the silence you understand what they were counting toward — and that the count did not begin with this temple, and does not end with it.",
|
||||
ZoneManorBlackspire: "Aldric was hollowed the same way, by the same hand, and made a poor imitation: a lord kept past his death to hold a house for a guest who never came. He thanks you. It is the first thing he has meant in a century.",
|
||||
ZoneUnderforge: "Thyrak's fires gutter out, and the half-made things on the anvils cool into what they were always going to be — regalia, and soldiers, and a crown with no head to fit. The forge was filling an order placed a long time ago.",
|
||||
ZoneUnderdark: "Ilvaras ruled the throat that swallows everything downward, toward the door at the bottom of the world. She dies certain she served a queen. She served a direction, and the direction has a name it never told her.",
|
||||
ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.",
|
||||
ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.",
|
||||
ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.",
|
||||
}
|
||||
|
||||
// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for
|
||||
// zones with none (and for the synthetic arena, which has no ZoneID entry).
|
||||
func bossEpilogueLine(zoneID ZoneID) string {
|
||||
return bossEpilogues[zoneID]
|
||||
}
|
||||
|
||||
// writeBossEpilogue appends a zone's campaign capstone (if any) to a
|
||||
// victory narration. Shared by every boss-down render path — the manual
|
||||
// turn-based finishes (finishCombatSession, finishPartyWin) and the compact
|
||||
// autopilot boss resolve — so the D1b epilogue fires no matter how the boss
|
||||
// was cleared. Caller gates on "this was a boss, not an elite".
|
||||
func writeBossEpilogue(b *strings.Builder, zoneID ZoneID) {
|
||||
if ep := bossEpilogueLine(zoneID); ep != "" {
|
||||
b.WriteString("\n" + ep + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// twinBeeJournalReactions are TwinBee's morning/digest reactions to pages found
|
||||
// during the day — first-person, implicit subject, he/him, one line, curious,
|
||||
// never expository (feedback_twinbee_voice, feedback_twinbee_is_male). Picked
|
||||
// deterministically so a re-rendered digest reads the same.
|
||||
var twinBeeJournalReactions = []string{
|
||||
"📖 Found a torn page in your kit tonight — been reading it by the fire while you sleep. This king of theirs was not a well man.",
|
||||
"📖 Another page. Keep turning them up and I keep piecing him together, and I do not much like the shape.",
|
||||
"📖 Read the new page twice. Whoever wrote it was frightened of something patient. I think we are walking toward it.",
|
||||
"📖 Slipped the day's page into the others. The story's filling in at the edges, and none of the edges are kind.",
|
||||
}
|
||||
|
||||
// twinBeeJournalReaction picks one reaction line deterministically from the day
|
||||
// and the number of pages found, so the digest is stable across re-renders.
|
||||
func twinBeeJournalReaction(day, pagesToday int) string {
|
||||
if len(twinBeeJournalReactions) == 0 || pagesToday <= 0 {
|
||||
return ""
|
||||
}
|
||||
idx := (day + pagesToday) % len(twinBeeJournalReactions)
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
return twinBeeJournalReactions[idx]
|
||||
}
|
||||
|
||||
// handleJournalCmd renders the player's collected campaign pages.
|
||||
func (p *AdventurePlugin) handleJournalCmd(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, renderJournal(char.JournalPages))
|
||||
}
|
||||
|
||||
// renderJournal builds the `!adventure journal` view: discovered pages in story
|
||||
// order, with runs of missing pages collapsed to a single "…".
|
||||
func renderJournal(mask int64) string {
|
||||
found := journalPageCount(mask)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("📖 **The Hollow King** — Journal (%d / %d pages)\n",
|
||||
found, journalTotalPages))
|
||||
|
||||
if found == 0 {
|
||||
b.WriteString("\nYou carry no pages yet. They surface where the realm's fragments gather — in the hands of elites, and behind doors most adventurers walk past.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
gapOpen := false
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if journalPageFound(mask, i) {
|
||||
gapOpen = false
|
||||
jp := journalPages[i-1]
|
||||
b.WriteString(fmt.Sprintf("\n**%s. %s**\n%s\n", romanNumeral(i), jp.Title, jp.Text))
|
||||
} else if !gapOpen {
|
||||
gapOpen = true
|
||||
b.WriteString("\n…\n")
|
||||
}
|
||||
}
|
||||
|
||||
if journalComplete(mask) {
|
||||
b.WriteString("\nThe ledger is whole. Clear both Tier-5 zones and you can follow him to the end — `!expedition start epilogue`.")
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("\n_%d pages still scattered._", journalTotalPages-found))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// romanNumeral renders 1..24 as upper-case Roman numerals for the page headers.
|
||||
// The campaign never exceeds a couple dozen pages, so a small table beats a
|
||||
// general algorithm here.
|
||||
func romanNumeral(n int) string {
|
||||
if n < 1 || n > len(romanNumerals) {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
return romanNumerals[n-1]
|
||||
}
|
||||
|
||||
// ── D1c: the finale ─────────────────────────────────────────────────────────
|
||||
|
||||
// finaleTitle is the unique role awarded for the first finale clear.
|
||||
const finaleTitle = "Kingsbane"
|
||||
|
||||
const epilogueEntryFlavor = "You bring the whole ledger to the Empty Throne. The shape kept warm against his return stirs, and stands, and is finally, fully here."
|
||||
|
||||
// hollowKingFinaleMonster is the finale stat block: Belaxath's block — the abyss
|
||||
// boss whose gate "was meant to let one thing come home" — re-dressed as the
|
||||
// King returned in full, with HP bumped for a capstone. A variant stat block
|
||||
// with no new mechanics, per the plan. Built on the fly (not registered in
|
||||
// dndBestiary) because runZoneCombat takes the template directly, exactly as the
|
||||
// arena bosses do.
|
||||
func hollowKingFinaleMonster() DnDMonsterTemplate {
|
||||
m := dndBestiary["boss_belaxath"]
|
||||
m.ID = "boss_hollow_king_finale"
|
||||
m.Name = "The Hollow King, Unhoused"
|
||||
m.HP = int(float64(m.HP) * 1.25)
|
||||
m.Notes = "Finale encounter — the Hollow King returned in full through the abyss gate. Variant of the Balor stat block; no new mechanics."
|
||||
return m
|
||||
}
|
||||
|
||||
// epilogueUnlocked gates the finale on the whole ledger plus both Tier-5 clears.
|
||||
func (p *AdventurePlugin) epilogueUnlocked(char *AdventureCharacter) (bool, string) {
|
||||
if !journalComplete(char.JournalPages) {
|
||||
return false, fmt.Sprintf(
|
||||
"The trail runs cold. You've recovered **%d of %d** journal pages — find them all before you can follow him to the end. (`!adventure journal`)",
|
||||
journalPageCount(char.JournalPages), journalTotalPages)
|
||||
}
|
||||
if !clearedEveryZoneOfTier(db.Get(), char.UserID, ZoneTier(5)) {
|
||||
return false, "You hold the whole ledger, but not the standing to use it. Clear **both** Tier-5 zones — the Dragon's Lair and the Abyss Portal — then come back to the throne."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// handleEpilogueEncounter runs the finale: a single auto-resolved boss fight
|
||||
// against the Hollow King's true form, reached via `!expedition start epilogue`.
|
||||
// Reward drops only on the first clear (reward-once); later clears are a
|
||||
// flavour-only rematch. A loss costs a death, as any boss fight does.
|
||||
func (p *AdventurePlugin) handleEpilogueEncounter(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` first.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're in no shape to face him. Mend at `!hospital` first.")
|
||||
}
|
||||
if ok, why := p.epilogueUnlocked(char); !ok {
|
||||
return p.SendDM(ctx.Sender, why)
|
||||
}
|
||||
// Busy guards — the finale is a synchronous auto-resolved fight, so refuse if
|
||||
// anything else is in flight and could collide on HP or run state.
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
return p.SendDM(ctx.Sender, "Finish your expedition before you walk to the Empty Throne.")
|
||||
}
|
||||
if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
|
||||
return p.SendDM(ctx.Sender, "Finish your current zone run first.")
|
||||
}
|
||||
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
|
||||
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it first.")
|
||||
}
|
||||
|
||||
monster := hollowKingFinaleMonster()
|
||||
displayName, _ := loadDisplayName(ctx.Sender)
|
||||
if displayName == "" {
|
||||
displayName = "You"
|
||||
}
|
||||
|
||||
preHP, _ := dndHPSnapshot(ctx.Sender)
|
||||
result, err := p.runZoneCombat(ctx.Sender, monster, 5, bossCombatPhases, 50)
|
||||
if err != nil {
|
||||
slog.Error("epilogue: combat failed", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong at the throne. Try again in a moment.")
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(ctx.Sender)
|
||||
nat20s, nat1s := countNat20sAnd1s(result)
|
||||
|
||||
intro := fmt.Sprintf("👑 **The Empty Throne — %s** (HP %d, AC %d)\n_%s_",
|
||||
monster.Name, monster.HP, monster.AC, epilogueEntryFlavor)
|
||||
phases := RenderCombatLog(result, displayName, monster.Name)
|
||||
outcome := renderBossOutcome(BossOutcomeInputs{
|
||||
// Synthetic ZoneArena keeps twinBeeLine's deterministic pickers off any
|
||||
// real zone's pool, exactly as the arena does.
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "epilogue-" + string(ctx.Sender),
|
||||
RoomIdx: 0,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: preHP,
|
||||
PostHP: postHP,
|
||||
MaxHP: maxHP,
|
||||
PhaseTwoAt: 0.40,
|
||||
Nat20s: nat20s,
|
||||
Nat1s: nat1s,
|
||||
DefeatHeadline: "💀 The Hollow King folds you into the quiet he keeps. The account goes unpaid a while longer.",
|
||||
VictoryHeadline: fmt.Sprintf("🏆 **The Hollow King falls — and this time stays down.** You finished at **%d/%d HP**.", postHP, maxHP),
|
||||
})
|
||||
|
||||
var tail string
|
||||
if !result.PlayerWon {
|
||||
markAdventureDead(ctx.Sender, "adventure", "the Empty Throne")
|
||||
} else {
|
||||
tail = p.finishEpilogueWin(ctx.Sender, char.EpilogueCleared)
|
||||
}
|
||||
|
||||
_ = p.sendZoneCombatMessages(ctx.Sender, append([]string{intro}, phases...), joinLootLines(outcome, tail))
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishEpilogueWin grants the reward on a first clear and returns the reward
|
||||
// narration; a repeat clear is flavour only. The character is reloaded before
|
||||
// the title write so runZoneCombat's post-fight HP persist isn't clobbered.
|
||||
func (p *AdventurePlugin) finishEpilogueWin(userID id.UserID, alreadyCleared bool) string {
|
||||
if alreadyCleared {
|
||||
return "_The throne stays empty. You came to be sure. You are sure._"
|
||||
}
|
||||
|
||||
// Latch the once-only flag BEFORE handing out the Legendary + title, so a
|
||||
// failed write (or a crash) can never leave the reward repeatable. If the
|
||||
// latch fails, skip the grant entirely — the player re-enters uncleared and
|
||||
// can try again, rather than pocketing a second Legendary on the retry.
|
||||
if err := markEpilogueClearedDB(userID); err != nil {
|
||||
slog.Error("epilogue: mark cleared failed", "user", userID, "err", err)
|
||||
return "_The account won't quite close — the ledger jams. Try `!expedition start epilogue` again._"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("🎖️ **The account is closed.** You are named **" + finaleTitle + "** — the one who unhoused the Hollow King.\n")
|
||||
if mi, ok := pickMagicItemForRarity(RarityLegendary, nil); ok {
|
||||
if line := p.dropMagicItemLoot(userID, mi, LootTierLegendary); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
} else {
|
||||
slog.Error("epilogue: no legendary in registry", "user", userID)
|
||||
}
|
||||
|
||||
if fresh, err := loadAdvCharacter(userID); err == nil && fresh != nil {
|
||||
if fresh.Title != finaleTitle {
|
||||
fresh.Title = finaleTitle
|
||||
if err := saveAdvCharacter(fresh); err != nil {
|
||||
slog.Error("epilogue: title save failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gr := gamesRoom(); gr != "" {
|
||||
if dn, _ := loadDisplayName(userID); dn != "" {
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"👑 **%s** carried the whole ledger to the Empty Throne and closed the account. The Hollow King is ended. **%s.**",
|
||||
dn, finaleTitle))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var romanNumerals = []string{
|
||||
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X",
|
||||
"XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX",
|
||||
"XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII", "XXVIII", "XXIX", "XXX",
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestJournalGrant_DBRoundTripIsAtomicOR(t *testing.T) {
|
||||
townTestDB(t)
|
||||
u := id.UserID("@page:test.invalid")
|
||||
|
||||
// No row yet → zero pages.
|
||||
if mask, err := loadJournalPages(u); err != nil || mask != 0 {
|
||||
t.Fatalf("fresh player: mask=%d err=%v, want 0/nil", mask, err)
|
||||
}
|
||||
|
||||
// First grant creates the row (ON CONFLICT INSERT path).
|
||||
if err := grantJournalPageDB(u, 5); err != nil {
|
||||
t.Fatalf("grant page 5: %v", err)
|
||||
}
|
||||
// Second grant ORs into the existing row without clobbering the first.
|
||||
if err := grantJournalPageDB(u, 2); err != nil {
|
||||
t.Fatalf("grant page 2: %v", err)
|
||||
}
|
||||
// Re-granting a found page is idempotent.
|
||||
if err := grantJournalPageDB(u, 5); err != nil {
|
||||
t.Fatalf("re-grant page 5: %v", err)
|
||||
}
|
||||
|
||||
mask, err := loadJournalPages(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !journalPageFound(mask, 2) || !journalPageFound(mask, 5) {
|
||||
t.Fatalf("pages 2 and 5 should be found, mask=%b", mask)
|
||||
}
|
||||
if journalPageCount(mask) != 2 {
|
||||
t.Fatalf("count=%d, want 2 (mask=%b)", journalPageCount(mask), mask)
|
||||
}
|
||||
|
||||
// Overlay populates the character field the viewer reads.
|
||||
var c AdventureCharacter
|
||||
c.UserID = u
|
||||
applyPlayerMetaOverlay(&c)
|
||||
if c.JournalPages != mask {
|
||||
t.Fatalf("overlay JournalPages=%b, want %b", c.JournalPages, mask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalCatalog_WellFormed(t *testing.T) {
|
||||
if journalTotalPages != len(journalPages) {
|
||||
t.Fatalf("journalTotalPages %d != len(journalPages) %d", journalTotalPages, len(journalPages))
|
||||
}
|
||||
if journalTotalPages < 1 || journalTotalPages > 63 {
|
||||
t.Fatalf("journal must fit an int64 bitmask (1..63); got %d", journalTotalPages)
|
||||
}
|
||||
if journalTotalPages > len(romanNumerals) {
|
||||
t.Fatalf("more pages (%d) than roman numerals (%d)", journalTotalPages, len(romanNumerals))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, jp := range journalPages {
|
||||
if strings.TrimSpace(jp.Title) == "" {
|
||||
t.Errorf("page %d has empty title", i+1)
|
||||
}
|
||||
if strings.TrimSpace(jp.Text) == "" {
|
||||
t.Errorf("page %d (%q) has empty text", i+1, jp.Title)
|
||||
}
|
||||
if seen[jp.Title] {
|
||||
t.Errorf("duplicate page title %q", jp.Title)
|
||||
}
|
||||
seen[jp.Title] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalBitmask_GrantQueryCount(t *testing.T) {
|
||||
var mask int64
|
||||
if journalPageCount(mask) != 0 {
|
||||
t.Fatalf("empty mask should count 0")
|
||||
}
|
||||
if journalPageFound(mask, 1) {
|
||||
t.Fatalf("page 1 should be unfound on empty mask")
|
||||
}
|
||||
mask = setJournalPageBit(mask, 3)
|
||||
mask = setJournalPageBit(mask, 1)
|
||||
if !journalPageFound(mask, 3) || !journalPageFound(mask, 1) {
|
||||
t.Fatalf("pages 1 and 3 should be found")
|
||||
}
|
||||
if journalPageFound(mask, 2) {
|
||||
t.Fatalf("page 2 should still be unfound")
|
||||
}
|
||||
if got := journalPageCount(mask); got != 2 {
|
||||
t.Fatalf("count = %d, want 2", got)
|
||||
}
|
||||
// Re-setting a found page is idempotent.
|
||||
if again := setJournalPageBit(mask, 3); again != mask {
|
||||
t.Fatalf("re-granting page 3 changed the mask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickUnfoundJournalPage_ExhaustsToZero(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
var mask int64
|
||||
found := map[int]bool{}
|
||||
for i := 0; i < journalTotalPages; i++ {
|
||||
page := pickUnfoundJournalPage(mask, rng)
|
||||
if page < 1 || page > journalTotalPages {
|
||||
t.Fatalf("pick %d out of range", page)
|
||||
}
|
||||
if found[page] {
|
||||
t.Fatalf("pick returned already-found page %d", page)
|
||||
}
|
||||
found[page] = true
|
||||
mask = setJournalPageBit(mask, page)
|
||||
}
|
||||
if !journalComplete(mask) {
|
||||
t.Fatalf("mask should be complete after %d picks", journalTotalPages)
|
||||
}
|
||||
if page := pickUnfoundJournalPage(mask, rng); page != 0 {
|
||||
t.Fatalf("pick on complete mask = %d, want 0", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderJournal_EmptyPartialFull(t *testing.T) {
|
||||
// Empty.
|
||||
empty := renderJournal(0)
|
||||
if !strings.Contains(empty, "0 / ") {
|
||||
t.Errorf("empty journal should show 0 found:\n%s", empty)
|
||||
}
|
||||
if strings.Contains(empty, "…") {
|
||||
t.Errorf("empty journal should not render a gap marker:\n%s", empty)
|
||||
}
|
||||
|
||||
// Partial with a gap: pages 1 and 3 found, 2 missing → exactly one "…".
|
||||
var partial int64
|
||||
partial = setJournalPageBit(partial, 1)
|
||||
partial = setJournalPageBit(partial, 3)
|
||||
pv := renderJournal(partial)
|
||||
if !strings.Contains(pv, journalPages[0].Title) || !strings.Contains(pv, journalPages[2].Title) {
|
||||
t.Errorf("partial journal missing a found page title:\n%s", pv)
|
||||
}
|
||||
if strings.Contains(pv, journalPages[1].Title) {
|
||||
t.Errorf("partial journal leaked an unfound page's text:\n%s", pv)
|
||||
}
|
||||
if !strings.Contains(pv, "…") {
|
||||
t.Errorf("partial journal with a gap should render '…':\n%s", pv)
|
||||
}
|
||||
|
||||
// Full: every title present, no gap, completion line.
|
||||
var full int64
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
full = setJournalPageBit(full, i)
|
||||
}
|
||||
fv := renderJournal(full)
|
||||
if strings.Contains(fv, "…") {
|
||||
t.Errorf("complete journal should have no gaps:\n%s", fv)
|
||||
}
|
||||
for _, jp := range journalPages {
|
||||
if !strings.Contains(fv, jp.Title) {
|
||||
t.Errorf("complete journal missing page %q", jp.Title)
|
||||
}
|
||||
}
|
||||
if !journalComplete(full) {
|
||||
t.Fatalf("full mask should be complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalDropChance_Sane(t *testing.T) {
|
||||
if journalPageDropChance <= 0 || journalPageDropChance >= 1 {
|
||||
t.Fatalf("drop chance %v out of (0,1)", journalPageDropChance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBossEpilogues_EveryRegisteredZoneHasOne(t *testing.T) {
|
||||
for _, z := range allZones() {
|
||||
if strings.TrimSpace(bossEpilogueLine(z.ID)) == "" {
|
||||
t.Errorf("zone %s (%s) has no boss epilogue", z.ID, z.Display)
|
||||
}
|
||||
}
|
||||
// The synthetic arena is not a campaign zone — it must have none.
|
||||
if bossEpilogueLine(ZoneArena) != "" {
|
||||
t.Errorf("arena should have no boss epilogue")
|
||||
}
|
||||
if bossEpilogueLine(ZoneID("nonexistent")) != "" {
|
||||
t.Errorf("unknown zone should have no epilogue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHollowKingFinaleMonster_ClonesBelaxathWithBump(t *testing.T) {
|
||||
base := dndBestiary["boss_belaxath"]
|
||||
if base.ID == "" {
|
||||
t.Fatal("boss_belaxath missing from bestiary — finale clones it")
|
||||
}
|
||||
m := hollowKingFinaleMonster()
|
||||
if m.ID != "boss_hollow_king_finale" {
|
||||
t.Errorf("finale ID = %q", m.ID)
|
||||
}
|
||||
if m.Name == base.Name || m.Name == "" {
|
||||
t.Errorf("finale name should differ from Belaxath: %q", m.Name)
|
||||
}
|
||||
if m.HP <= base.HP {
|
||||
t.Errorf("finale HP %d should exceed base %d (capstone bump)", m.HP, base.HP)
|
||||
}
|
||||
// No new mechanics: the ability and defensive profile carry over unchanged.
|
||||
if m.Ability != base.Ability {
|
||||
t.Errorf("finale ability pointer changed — should reuse Belaxath's, no new mechanics")
|
||||
}
|
||||
if m.AC != base.AC || m.CR != base.CR {
|
||||
t.Errorf("finale AC/CR drifted from base: AC %d/%d CR %v/%v", m.AC, base.AC, m.CR, base.CR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpilogueRewardOnce_FlagRoundTrip(t *testing.T) {
|
||||
townTestDB(t)
|
||||
u := id.UserID("@finale:test.invalid")
|
||||
|
||||
if got, err := loadEpilogueCleared(u); err != nil || got {
|
||||
t.Fatalf("fresh player: cleared=%v err=%v, want false/nil", got, err)
|
||||
}
|
||||
if err := markEpilogueClearedDB(u); err != nil {
|
||||
t.Fatalf("mark cleared: %v", err)
|
||||
}
|
||||
if got, err := loadEpilogueCleared(u); err != nil || !got {
|
||||
t.Fatalf("after mark: cleared=%v err=%v, want true/nil", got, err)
|
||||
}
|
||||
// Idempotent.
|
||||
if err := markEpilogueClearedDB(u); err != nil {
|
||||
t.Fatalf("re-mark: %v", err)
|
||||
}
|
||||
|
||||
var c AdventureCharacter
|
||||
c.UserID = u
|
||||
applyPlayerMetaOverlay(&c)
|
||||
if !c.EpilogueCleared {
|
||||
t.Fatalf("overlay did not carry EpilogueCleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpilogueUnlocked_Gates(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
// Incomplete journal → refused, page-count message.
|
||||
partial := &AdventureCharacter{UserID: id.UserID("@u1:test.invalid")}
|
||||
partial.JournalPages = setJournalPageBit(0, 1)
|
||||
ok, why := p.epilogueUnlocked(partial)
|
||||
if ok {
|
||||
t.Fatalf("incomplete journal should not unlock")
|
||||
}
|
||||
if !strings.Contains(why, "journal pages") {
|
||||
t.Errorf("expected page-count refusal, got: %s", why)
|
||||
}
|
||||
|
||||
// Complete journal but no Tier-5 clears → refused, T5 message.
|
||||
full := &AdventureCharacter{UserID: id.UserID("@u2:test.invalid")}
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
full.JournalPages = setJournalPageBit(full.JournalPages, i)
|
||||
}
|
||||
ok, why = p.epilogueUnlocked(full)
|
||||
if ok {
|
||||
t.Fatalf("no T5 clears should not unlock")
|
||||
}
|
||||
if !strings.Contains(why, "Tier-5") {
|
||||
t.Errorf("expected Tier-5 refusal, got: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishEpilogueWin_RepeatIsFlavorOnly(t *testing.T) {
|
||||
// The already-cleared branch is pure flavour: no reward, no client calls.
|
||||
p := &AdventurePlugin{}
|
||||
repeat := p.finishEpilogueWin(id.UserID("@u:test.invalid"), true)
|
||||
if strings.Contains(repeat, finaleTitle) {
|
||||
t.Errorf("a repeat clear must not re-award the title: %s", repeat)
|
||||
}
|
||||
if strings.TrimSpace(repeat) == "" {
|
||||
t.Errorf("a repeat clear should still say something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwinBeeJournalReaction_DeterministicAndGuarded(t *testing.T) {
|
||||
if twinBeeJournalReaction(3, 0) != "" {
|
||||
t.Errorf("zero pages should produce no reaction")
|
||||
}
|
||||
first := twinBeeJournalReaction(3, 2)
|
||||
if first == "" {
|
||||
t.Fatalf("a found page should produce a reaction")
|
||||
}
|
||||
if again := twinBeeJournalReaction(3, 2); again != first {
|
||||
t.Errorf("reaction not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
// Voice guard: TwinBee never speaks of himself in the third person
|
||||
// (feedback_twinbee_voice). None of the pooled lines may contain "TwinBee".
|
||||
for _, line := range twinBeeJournalReactions {
|
||||
if strings.Contains(line, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee reference in reaction: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -924,4 +924,4 @@ var DungeonExceptional = map[int][]string{
|
||||
"in a dungeon. Write it down. " +
|
||||
"The dungeon will tell this story differently.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -24,31 +24,6 @@ var mistyAcceptLines = []string{
|
||||
|
||||
const mistyDeclineLine = "She nods once and walks away. She doesn't look back."
|
||||
|
||||
// ── Misty — Arc Beats (D2) ─────────────────────────────────────────────────
|
||||
//
|
||||
// Three deepening dialogue beats keyed to MistyEncounterCount (5 / 15 / 30),
|
||||
// prepended to the encounter opening the one time the counter lands on a
|
||||
// threshold. Fiction only: nothing here connects a donation to a combat
|
||||
// outcome — the arena effects are a hidden discovery mechanic and stay hidden.
|
||||
var mistyArcBeats = map[int]string{
|
||||
5: "It's you again. I was hoping it would be. I know your face now -- you're one of the few who ever stops. " +
|
||||
"Most people look right through me. You never have.",
|
||||
|
||||
15: "Can I tell you something? I wasn't always out here asking strangers on the road. " +
|
||||
"I had a home once. A garden that got away from me every summer. Someone who used to wait up. " +
|
||||
"It's gone now, all of it. But you keep reminding me there are still kind people in the world. That's not nothing.",
|
||||
|
||||
30: "I don't think of you as a stranger anymore. Is it strange to say that? " +
|
||||
"I catch myself watching the road for you now -- not for the gold, though I won't pretend it doesn't help. " +
|
||||
"Just to see you're still standing. Whatever's out there, I hope it knows better than to take you from me too.",
|
||||
}
|
||||
|
||||
// mistyArcBeat returns the deepening dialogue for the exact encounter count at
|
||||
// which a Misty stage lands, or "" for every other encounter.
|
||||
func mistyArcBeat(encounterCount int) string {
|
||||
return mistyArcBeats[encounterCount]
|
||||
}
|
||||
|
||||
// ── Arina — Opening Lines ──────────────────────────────────────────────────
|
||||
|
||||
var arinaOpenings = []string{
|
||||
|
||||
@@ -275,7 +275,6 @@ 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.
|
||||
|
||||
@@ -40,11 +40,6 @@ var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excel
|
||||
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
|
||||
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
|
||||
|
||||
// robbieLeftConsumable is appended when Robbie leaves a little something on his
|
||||
// every-10th-visit (D2). Takes the item name.
|
||||
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
|
||||
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
|
||||
|
||||
// ── Room Announcements ───────────────────────────────────────────────────────
|
||||
|
||||
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
||||
|
||||
@@ -97,12 +97,12 @@ func TestAdvMasteryRowSegment(t *testing.T) {
|
||||
|
||||
func TestAdvMasteryRollup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
used map[EquipmentSlot]int
|
||||
wantCrossed int
|
||||
wantMaxed int
|
||||
wantBonus float64
|
||||
wantAnyProgress bool
|
||||
name string
|
||||
used map[EquipmentSlot]int
|
||||
wantCrossed int
|
||||
wantMaxed int
|
||||
wantBonus float64
|
||||
wantAnyProgress bool
|
||||
}{
|
||||
{"empty", nil, 0, 0, 0, false},
|
||||
{"sub-50 only", map[EquipmentSlot]int{SlotWeapon: 30}, 0, 0, 0, true},
|
||||
@@ -240,12 +240,12 @@ func TestAdvTreasureIrreplaceable(t *testing.T) {
|
||||
|
||||
func TestRenderCraftingTeaser_BracketBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
foraging int
|
||||
craftsSucceeded int
|
||||
wantEmpty bool
|
||||
wantContainsAny []string // any of these (OR)
|
||||
wantNotContains []string
|
||||
name string
|
||||
foraging int
|
||||
craftsSucceeded int
|
||||
wantEmpty bool
|
||||
wantContainsAny []string // any of these (OR)
|
||||
wantNotContains []string
|
||||
}{
|
||||
{"foraging 6 — out of pre-window", 6, 0, true, nil, nil},
|
||||
{"foraging 7 — pre-unlock with 3 levels left, plural", 7, 0, false, []string{"3 Foraging level"}, []string{"unlocks in 1 ", "unlocks in 0"}},
|
||||
@@ -297,7 +297,7 @@ func TestCraftingReminderWeekday_Spread(t *testing.T) {
|
||||
|
||||
func TestCraftingReminderWeekday_StableWithinWeek(t *testing.T) {
|
||||
uid := id.UserID("@stable:test")
|
||||
mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday
|
||||
mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday
|
||||
sun := time.Date(2026, 5, 10, 23, 0, 0, 0, time.UTC) // Sunday same ISO week
|
||||
if craftingReminderWeekday(uid, mon) != craftingReminderWeekday(uid, sun) {
|
||||
t.Error("weekday should be stable within an ISO week")
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N4/E2 item gifting. `!give <item> @user` hands a consumable or non-magic gear
|
||||
// item to another adventurer. The sender pays a 5% handling fee (of item value)
|
||||
// to the community pot — the same civic tax every payout pays — and is capped at
|
||||
// a few gifts a day so the feature can't become a twink-funnel for a fresh alt.
|
||||
// Magic items are deliberately non-giftable: attunement and the BiS economy stay
|
||||
// personal (gogobee_engagement_plan.md §E2).
|
||||
const (
|
||||
giftDailyCap = 3
|
||||
giftTaxRate = 0.05
|
||||
)
|
||||
|
||||
// giftableTypes is the allowlist of AdvItem.Type values a player may gift.
|
||||
// Everything in adventure_inventory is unequipped by definition (equipped gear
|
||||
// lives in adventure_equipment / magic_item_equipped), so "unequipped" needs no
|
||||
// separate check. Magic items ("magic_item") and raw materials are excluded.
|
||||
var giftableTypes = map[string]bool{
|
||||
"consumable": true,
|
||||
"MasterworkGear": true,
|
||||
"ArenaGear": true,
|
||||
}
|
||||
|
||||
// isGiftableItem reports whether an inventory item may be gifted, and a reason
|
||||
// when it may not.
|
||||
func isGiftableItem(it AdvItem) (bool, string) {
|
||||
if it.Type == "magic_item" {
|
||||
return false, "Magic items stay personal — attunement doesn't transfer. Try a consumable or a piece of gear."
|
||||
}
|
||||
if !giftableTypes[it.Type] {
|
||||
return false, "You can only gift consumables and non-magic gear. Raw materials and treasures aren't giftable."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// pickGiftableItem resolves the item a `!give` query names, preferring a giftable
|
||||
// match so a non-giftable item (a magic item, raw materials) that also matches
|
||||
// can't shadow a giftable one and block the gift — the way plain findInventoryMatch
|
||||
// would, since it returns the first match across the whole inventory. Returns
|
||||
// (item, "") on success; (nil, "") when nothing matches at all; and (nil, reason)
|
||||
// when only non-giftable items match, so the caller can explain why.
|
||||
func pickGiftableItem(inv []AdvItem, query string) (*AdvItem, string) {
|
||||
match := findInventoryMatch(inv, query)
|
||||
if match == nil {
|
||||
return nil, ""
|
||||
}
|
||||
if ok, _ := isGiftableItem(*match); ok {
|
||||
return match, ""
|
||||
}
|
||||
giftables := make([]AdvItem, 0, len(inv))
|
||||
for _, it := range inv {
|
||||
if ok, _ := isGiftableItem(it); ok {
|
||||
giftables = append(giftables, it)
|
||||
}
|
||||
}
|
||||
if alt := findInventoryMatch(giftables, query); alt != nil {
|
||||
return alt, ""
|
||||
}
|
||||
_, reason := isGiftableItem(*match)
|
||||
return nil, reason
|
||||
}
|
||||
|
||||
// giftCountToday returns how many gifts the sender has already sent on the given
|
||||
// UTC day. The cap is enforced against the persisted log, so a restart can't
|
||||
// reset someone's daily allowance.
|
||||
func giftCountToday(sender id.UserID, day string) int {
|
||||
var n int
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM adventure_gift_log WHERE sender = ? AND gift_day = ?`,
|
||||
string(sender), day).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// logGift records a completed gift for the daily cap and the audit trail.
|
||||
func logGift(sender, recipient id.UserID, itemName string, value int64, day string) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO adventure_gift_log (sender, recipient, item_name, value, gift_day)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
string(sender), string(recipient), itemName, value, day)
|
||||
return err
|
||||
}
|
||||
|
||||
// transferInventoryItem moves one inventory row from one owner to another. Scoped
|
||||
// to the current owner so a stale id can't move a row that has already changed
|
||||
// hands, and forces in_vault=0 so a gift always lands in the recipient's active
|
||||
// pack rather than a phantom vault slot.
|
||||
func transferInventoryItem(itemID int64, from, to id.UserID) (bool, error) {
|
||||
res, err := db.Get().Exec(
|
||||
`UPDATE adventure_inventory SET user_id = ?, in_vault = 0 WHERE id = ? AND user_id = ?`,
|
||||
string(to), itemID, string(from))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// handleGiveCmd routes `!give <item> @user`.
|
||||
func (p *AdventurePlugin) handleGiveCmd(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "give"))
|
||||
|
||||
// Serialize a sender's own gifts so two near-simultaneous !give commands
|
||||
// can't both clear the daily cap or double-spend the same item row.
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. Your things stay where they are for now.")
|
||||
}
|
||||
|
||||
// The target is the trailing token; everything before it is the item name.
|
||||
fields := strings.Fields(args)
|
||||
if len(fields) < 2 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!give <item> @user` — e.g. `!give Health Potion @alex`.")
|
||||
}
|
||||
targetRaw := fields[len(fields)-1]
|
||||
itemQuery := strings.TrimSpace(strings.TrimSuffix(args, targetRaw))
|
||||
|
||||
target, ok := p.ResolveUser(targetRaw, ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Couldn't find a user matching %q. Mention them or use their exact name.", targetRaw))
|
||||
}
|
||||
if target == ctx.Sender {
|
||||
return p.SendDM(ctx.Sender, "Regifting to yourself? That's just moving things around.")
|
||||
}
|
||||
|
||||
// Recipient must have completed !setup.
|
||||
rc, rerr := LoadDnDCharacter(target)
|
||||
if rerr != nil || rc == nil || rc.PendingSetup {
|
||||
name := p.DisplayName(target)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s hasn't set up an adventurer yet — they need to run `!setup` before they can receive gifts.", name))
|
||||
}
|
||||
|
||||
day := time.Now().UTC().Format("2006-01-02")
|
||||
if sent := giftCountToday(ctx.Sender, day); sent >= giftDailyCap {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You've already sent %d gifts today (the daily limit). Try again tomorrow.", sent))
|
||||
}
|
||||
|
||||
inv, err := loadAdvInventory(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't reach your inventory. Try again in a moment.")
|
||||
}
|
||||
match, reason := pickGiftableItem(inv, itemQuery)
|
||||
if match == nil {
|
||||
if reason == "" {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("No inventory item matches %q. Check `!adventure inventory`.", itemQuery))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, reason)
|
||||
}
|
||||
|
||||
tax := int(math.Round(float64(match.Value) * giftTaxRate))
|
||||
if tax > 0 {
|
||||
if bal := p.euro.GetBalance(ctx.Sender); bal < float64(tax) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Gifting **%s** carries a €%d handling fee (5%% of its value), and you only have €%.0f.", match.Name, tax, bal))
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(tax), "adventure_gift_tax") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
|
||||
}
|
||||
}
|
||||
|
||||
moved, err := transferInventoryItem(match.ID, ctx.Sender, target)
|
||||
if err != nil || !moved {
|
||||
if tax > 0 {
|
||||
p.euro.Credit(ctx.Sender, float64(tax), "adventure_gift_refund")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't hand the item over. Nothing changed — try again in a moment.")
|
||||
}
|
||||
if tax > 0 {
|
||||
communityPotAdd(tax)
|
||||
trackTaxPaid(ctx.Sender, tax)
|
||||
}
|
||||
_ = logGift(ctx.Sender, target, match.Name, match.Value, day)
|
||||
|
||||
senderName := p.DisplayName(ctx.Sender)
|
||||
recipName := p.DisplayName(target)
|
||||
_ = p.SendDM(target, fmt.Sprintf("🎁 **%s** sent you **%s**! It's in your inventory now — `!adventure inventory` to take a look.", senderName, match.Name))
|
||||
|
||||
remaining := giftDailyCap - giftCountToday(ctx.Sender, day)
|
||||
feeNote := ""
|
||||
if tax > 0 {
|
||||
feeNote = fmt.Sprintf(" (€%d handling fee to the community pot)", tax)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🎁 Sent **%s** to %s%s. %d gift(s) left today.", match.Name, recipName, feeNote, remaining))
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestIsGiftableItem(t *testing.T) {
|
||||
cases := []struct {
|
||||
typ string
|
||||
want bool
|
||||
}{
|
||||
{"consumable", true},
|
||||
{"MasterworkGear", true},
|
||||
{"ArenaGear", true},
|
||||
{"magic_item", false},
|
||||
{"treasure", false},
|
||||
{"ore", false},
|
||||
{"card", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, reason := isGiftableItem(AdvItem{Type: c.typ, Name: "X"})
|
||||
if got != c.want {
|
||||
t.Errorf("isGiftableItem(%q) = %v, want %v (reason %q)", c.typ, got, c.want, reason)
|
||||
}
|
||||
if !got && reason == "" {
|
||||
t.Errorf("isGiftableItem(%q) rejected with no reason", c.typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickGiftableItem: a non-giftable item must not shadow a giftable one that
|
||||
// also matches the query.
|
||||
func TestPickGiftableItem(t *testing.T) {
|
||||
inv := []AdvItem{
|
||||
{ID: 1, Name: "Dragon Scale", Type: "material", Tier: 5, Value: 9000},
|
||||
{ID: 2, Name: "Dragon Draught", Type: "consumable", Tier: 1, Value: 200},
|
||||
}
|
||||
// "dragon" matches the Scale first (higher tier), but the Draught is the
|
||||
// giftable one — pick it, don't refuse.
|
||||
got, reason := pickGiftableItem(inv, "dragon")
|
||||
if got == nil {
|
||||
t.Fatalf("pickGiftableItem returned nil (reason %q), want the giftable Draught", reason)
|
||||
}
|
||||
if got.Name != "Dragon Draught" {
|
||||
t.Errorf("picked %q, want Dragon Draught", got.Name)
|
||||
}
|
||||
|
||||
// Only a non-giftable match → refuse with a reason.
|
||||
onlyMagic := []AdvItem{{ID: 3, Name: "Ring of Power", Type: "magic_item", Value: 5000}}
|
||||
got, reason = pickGiftableItem(onlyMagic, "ring")
|
||||
if got != nil || reason == "" {
|
||||
t.Errorf("magic-only match: got=%v reason=%q, want nil with a refusal reason", got, reason)
|
||||
}
|
||||
|
||||
// No match at all → nil, empty reason.
|
||||
got, reason = pickGiftableItem(inv, "nonexistent")
|
||||
if got != nil || reason != "" {
|
||||
t.Errorf("no match: got=%v reason=%q, want nil/empty", got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferInventoryItem: an item moves owners exactly, and a wrong-owner
|
||||
// transfer is a no-op.
|
||||
func TestTransferInventoryItem(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
alice := id.UserID("@alice:test.invalid")
|
||||
bob := id.UserID("@bob:test.invalid")
|
||||
if err := addAdvInventoryItem(alice, AdvItem{Name: "Health Potion", Type: "consumable", Tier: 1, Value: 200}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, _ := loadAdvInventory(alice)
|
||||
if len(inv) != 1 {
|
||||
t.Fatalf("setup: alice has %d items, want 1", len(inv))
|
||||
}
|
||||
itemID := inv[0].ID
|
||||
|
||||
// Wrong owner can't move it.
|
||||
if moved, err := transferInventoryItem(itemID, bob, alice); err != nil || moved {
|
||||
t.Fatalf("wrong-owner transfer moved=%v err=%v, want no-op", moved, err)
|
||||
}
|
||||
|
||||
// Real owner moves it to bob.
|
||||
moved, err := transferInventoryItem(itemID, alice, bob)
|
||||
if err != nil || !moved {
|
||||
t.Fatalf("transfer moved=%v err=%v, want success", moved, err)
|
||||
}
|
||||
if a, _ := loadAdvInventory(alice); len(a) != 0 {
|
||||
t.Errorf("alice still has %d items after gifting", len(a))
|
||||
}
|
||||
b, _ := loadAdvInventory(bob)
|
||||
if len(b) != 1 || b[0].Name != "Health Potion" {
|
||||
t.Errorf("bob's inventory = %+v, want the potion", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferUnvaultsItem: gifting a currently-vaulted item lands it in the
|
||||
// recipient's active pack, not a phantom vault slot.
|
||||
func TestTransferUnvaultsItem(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
alice := id.UserID("@alice2:test.invalid")
|
||||
bob := id.UserID("@bob2:test.invalid")
|
||||
if err := addAdvInventoryItem(alice, AdvItem{Name: "Elixir", Type: "consumable", Tier: 1, Value: 100}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, _ := loadAdvInventory(alice)
|
||||
if _, err := setItemVaulted(alice, inv[0].ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transferInventoryItem(inv[0].ID, alice, bob); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Bob sees it in active inventory (not vault).
|
||||
if b, _ := loadAdvInventory(bob); len(b) != 1 {
|
||||
t.Errorf("bob active inventory = %d, want 1 (item un-vaulted on gift)", len(b))
|
||||
}
|
||||
if bv, _ := loadAdvVault(bob); len(bv) != 0 {
|
||||
t.Errorf("bob vault = %d, want 0", len(bv))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGiftDailyCapCounting: logGift increments the day's count and the count is
|
||||
// day-scoped.
|
||||
func TestGiftDailyCapCounting(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
sender := id.UserID("@giver:test.invalid")
|
||||
recip := id.UserID("@taker:test.invalid")
|
||||
|
||||
if n := giftCountToday(sender, "2026-07-10"); n != 0 {
|
||||
t.Fatalf("initial count = %d, want 0", n)
|
||||
}
|
||||
for i := 0; i < giftDailyCap; i++ {
|
||||
if err := logGift(sender, recip, "Potion", 100, "2026-07-10"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := giftCountToday(sender, "2026-07-10"); n != giftDailyCap {
|
||||
t.Errorf("count = %d, want %d", n, giftDailyCap)
|
||||
}
|
||||
// A different day is a fresh allowance.
|
||||
if n := giftCountToday(sender, "2026-07-11"); n != 0 {
|
||||
t.Errorf("next-day count = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
|
||||
|
||||
// Store pending interaction
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "hospital_pay",
|
||||
Type: "hospital_pay",
|
||||
Data: &advPendingHospitalPay{
|
||||
Cost: afterInsurance,
|
||||
Discounted: haggleOutcome == haggleWin,
|
||||
@@ -277,7 +277,7 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
name, _ := loadDisplayName(char.UserID)
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -99,16 +99,12 @@ type advPendingHouseDownPayment struct {
|
||||
|
||||
type advPendingHouseAutopay struct{}
|
||||
|
||||
// Slot is the pet slot the arrival conversation is filling: 1 (the first pet)
|
||||
// or 2 (the Tier-4 Estate's second companion). Zero-value 1 keeps every
|
||||
// existing single-pet interaction unchanged.
|
||||
type advPendingPetArrival struct{ Slot int }
|
||||
type advPendingPetArrival struct{}
|
||||
|
||||
type advPendingPetType struct{ Slot int }
|
||||
type advPendingPetType struct{}
|
||||
|
||||
type advPendingPetName struct {
|
||||
PetType string
|
||||
Slot int
|
||||
}
|
||||
|
||||
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
|
||||
@@ -146,18 +142,6 @@ var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house
|
||||
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
|
||||
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
|
||||
|
||||
// thomPetTreatItem is the keepsake Thom leaves for the pet on the final
|
||||
// mortgage payoff. Inert (Type "card" like the medical-debt card): not a
|
||||
// consumable, not gear, and Robbie's sweep skips it.
|
||||
func thomPetTreatItem() AdvItem {
|
||||
return AdvItem{
|
||||
Name: "Krooke's Finest Pet Treat",
|
||||
Type: "card",
|
||||
Tier: 0,
|
||||
Value: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// thomGreeting returns an appropriate greeting based on pet state.
|
||||
func thomGreeting(char *AdventureCharacter) string {
|
||||
if char.HasPet() && char.PetLevel >= 10 {
|
||||
@@ -237,19 +221,11 @@ func thomShopView(char *AdventureCharacter, balance float64) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Pet supply shop (unlocks 1 week after pet level 10). The unlock is a
|
||||
// pet-1 mechanic but covers barding for both companions. Each pet's block is
|
||||
// gated on its own presence so a chased-away pet-1 doesn't hide pet-2's shop.
|
||||
if char.PetSupplyShopUnlocked && (char.HasPet() || char.HasPet2()) {
|
||||
// Pet supply shop (unlocks 1 week after pet level 10)
|
||||
if char.PetSupplyShopUnlocked && char.HasPet() {
|
||||
sb.WriteString("\n---\n")
|
||||
if char.HasPet() {
|
||||
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
|
||||
sb.WriteString(petArmorShopView(char.PetType, char.PetArmorTier, "petbuy"))
|
||||
}
|
||||
if char.HasPet2() {
|
||||
sb.WriteString(fmt.Sprintf("\n🐾 **Pet Supplies** — for %s\n\n", char.Pet2Name))
|
||||
sb.WriteString(petArmorShopView(char.Pet2Type, char.Pet2ArmorTier, "pet2buy"))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
|
||||
sb.WriteString(petArmorShopView(char))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -293,11 +269,8 @@ func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
|
||||
case lower == "buy" || strings.HasPrefix(lower, "buy "):
|
||||
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
|
||||
|
||||
case strings.HasPrefix(lower, "pet2buy "):
|
||||
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[8:]), 2)
|
||||
|
||||
case strings.HasPrefix(lower, "petbuy "):
|
||||
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[7:]), 1)
|
||||
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
|
||||
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
|
||||
@@ -565,22 +538,11 @@ func (p *AdventurePlugin) processMortgagePayments() {
|
||||
freshChar.HouseLoanBalance = 0
|
||||
freshChar.HouseMissedPayments = 0
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
// The last house: no next tier means Thom is done with this
|
||||
// player. A letter, and a treat in the bag if they keep a pet.
|
||||
finalPayoff := houseNextTier(freshChar.HouseTier) == nil
|
||||
if finalPayoff && freshChar.HasPet() {
|
||||
_ = addAdvInventoryItem(freshChar.UserID, thomPetTreatItem())
|
||||
}
|
||||
userMu.Unlock()
|
||||
switch {
|
||||
case finalPayoff && freshChar.HasPet():
|
||||
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", freshChar.PetName)
|
||||
_ = p.SendDM(freshChar.UserID, "🏠 "+letter)
|
||||
case finalPayoff:
|
||||
_ = p.SendDM(freshChar.UserID, "🏠 "+thomFinalLetterNoPet)
|
||||
case freshChar.HasPet():
|
||||
// Paid off!
|
||||
if freshChar.HasPet() {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
|
||||
default:
|
||||
} else {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
continue
|
||||
@@ -711,18 +673,18 @@ func petArmorDefs(petType string) []PetArmorDef {
|
||||
return petCatArmor
|
||||
}
|
||||
|
||||
func petArmorShopView(petType string, armorTier int, buyCmd string) string {
|
||||
func petArmorShopView(char *AdventureCharacter) string {
|
||||
var sb strings.Builder
|
||||
defs := petArmorDefs(petType)
|
||||
defs := petArmorDefs(char.PetType)
|
||||
|
||||
if armorTier >= 5 {
|
||||
if char.PetArmorTier >= 5 {
|
||||
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for _, def := range defs {
|
||||
if def.Tier <= armorTier {
|
||||
if def.Tier == armorTier {
|
||||
if def.Tier <= char.PetArmorTier {
|
||||
if def.Tier == char.PetArmorTier {
|
||||
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
|
||||
}
|
||||
continue
|
||||
@@ -730,14 +692,11 @@ func petArmorShopView(petType string, armorTier int, buyCmd string) string {
|
||||
indicator := "⬆️"
|
||||
sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom %s <tier>` (e.g., `!thom %s %d`)", buyCmd, buyCmd, armorTier+1))
|
||||
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// petArmorBuyForSlot buys the next barding tier for the given pet slot. The
|
||||
// supply-shop unlock is shared (a pet-1 mechanic), but each pet's armor tier is
|
||||
// tracked and purchased independently.
|
||||
func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string, slot int) error {
|
||||
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -747,16 +706,8 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
// Slot accessors keep the pet-1 and pet-2 field sets isolated.
|
||||
hasPet, petType, petName, curTier := char.HasPet(), char.PetType, char.PetName, char.PetArmorTier
|
||||
buyCmd := "petbuy"
|
||||
if slot == 2 {
|
||||
hasPet, petType, petName, curTier = char.HasPet2(), char.Pet2Type, char.Pet2Name, char.Pet2ArmorTier
|
||||
buyCmd = "pet2buy"
|
||||
}
|
||||
|
||||
if !hasPet {
|
||||
return p.SendDM(ctx.Sender, "You don't have that pet.")
|
||||
if !char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, "You don't have a pet.")
|
||||
}
|
||||
if !char.PetSupplyShopUnlocked {
|
||||
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
|
||||
@@ -765,19 +716,19 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
|
||||
tier := 0
|
||||
for _, c := range tierStr {
|
||||
if c < '0' || c > '9' {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Usage: `!thom %s <tier>`", buyCmd))
|
||||
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
|
||||
}
|
||||
tier = tier*10 + int(c-'0')
|
||||
}
|
||||
|
||||
if tier != curTier+1 {
|
||||
if tier <= curTier {
|
||||
if tier != char.PetArmorTier+1 {
|
||||
if tier <= char.PetArmorTier {
|
||||
return p.SendDM(ctx.Sender, "That's not an upgrade.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", curTier+1))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
|
||||
}
|
||||
|
||||
defs := petArmorDefs(petType)
|
||||
defs := petArmorDefs(char.PetType)
|
||||
var def *PetArmorDef
|
||||
for i := range defs {
|
||||
if defs[i].Tier == tier {
|
||||
@@ -791,26 +742,18 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(def.Price) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", petName, def.Price, balance))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance))
|
||||
}
|
||||
|
||||
reason := "pet_armor_" + tierStr
|
||||
if slot == 2 {
|
||||
reason = "pet2_armor_" + tierStr
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(def.Price), reason) {
|
||||
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
if slot == 2 {
|
||||
char.Pet2ArmorTier = tier
|
||||
} else {
|
||||
char.PetArmorTier = tier
|
||||
}
|
||||
char.PetArmorTier = tier
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Refunded.")
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, petName, def.DeflectBonus*100, petName))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName))
|
||||
}
|
||||
|
||||
@@ -144,10 +144,10 @@ func TestHouseHPBonus(t *testing.T) {
|
||||
expected float64
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 0}, // Base house — no bonus
|
||||
{2, 0.05}, // Livable
|
||||
{3, 0.12}, // Comfortable
|
||||
{4, 0.20}, // Established
|
||||
{1, 0}, // Base house — no bonus
|
||||
{2, 0.05}, // Livable
|
||||
{3, 0.12}, // Comfortable
|
||||
{4, 0.20}, // Established
|
||||
}
|
||||
for _, tt := range tests {
|
||||
char := &AdventureCharacter{HouseTier: tt.tier}
|
||||
@@ -285,7 +285,8 @@ func TestPetArmorDefs_BothTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||
text := petArmorShopView("dog", 0, "petbuy")
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
|
||||
text := petArmorShopView(char)
|
||||
|
||||
if !strings.Contains(text, "⬆️") {
|
||||
t.Error("should show upgrade indicators")
|
||||
@@ -296,7 +297,8 @@ func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_MaxTier(t *testing.T) {
|
||||
text := petArmorShopView("cat", 5, "petbuy")
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5}
|
||||
text := petArmorShopView(char)
|
||||
|
||||
if !strings.Contains(text, "Max pet armor") {
|
||||
t.Error("should show max tier message")
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// N1/A3 — every crafting ingredient must be obtainable from some live drop
|
||||
// source. Phase R retired the legacy activity loop, which orphaned
|
||||
// generateAdvLoot (reachable only from the now-uncalled resolveDungeonAction)
|
||||
// and with it every ingredient in the game. rollZoneIngredient reconnects the
|
||||
// legacy tables to zone combat; this test is the guard that keeps them
|
||||
// reachable.
|
||||
|
||||
// liveIngredientSources returns every item name a player can obtain from the
|
||||
// loot tables zone combat draws on, keyed to the tier it drops at.
|
||||
func liveIngredientSources() map[string][]int {
|
||||
sources := map[string][]int{}
|
||||
for _, act := range advIngredientActivities {
|
||||
table := advLootTable(act)
|
||||
for tier, defs := range table {
|
||||
for _, d := range defs {
|
||||
sources[d.Name] = append(sources[d.Name], tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func TestEveryRecipeIngredientHasALiveSource(t *testing.T) {
|
||||
sources := liveIngredientSources()
|
||||
for _, recipe := range craftingRecipes {
|
||||
for _, ing := range recipe.Ingredients {
|
||||
if tiers, ok := sources[ing]; !ok || len(tiers) == 0 {
|
||||
t.Errorf("recipe %q needs %q, which no live loot table drops",
|
||||
recipe.Result, ing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The drop is keyed by zone tier, so an ingredient that only exists at
|
||||
// legacy tier N is only reachable in a tier-N zone. Assert each recipe is
|
||||
// completable by someone — i.e. every ingredient sits in tier 1..5.
|
||||
func TestIngredientTiersAreReachableByZoneTier(t *testing.T) {
|
||||
sources := liveIngredientSources()
|
||||
for _, recipe := range craftingRecipes {
|
||||
for _, ing := range recipe.Ingredients {
|
||||
for _, tier := range sources[ing] {
|
||||
if tier < 1 || tier > 5 {
|
||||
t.Errorf("%q drops at tier %d, outside the zone tier range",
|
||||
ing, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every zone tier must be able to produce an ingredient, or that tier's
|
||||
// players are cut out of crafting entirely.
|
||||
func TestRollZoneIngredient_EveryTierCanDrop(t *testing.T) {
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
t.Run(fmt.Sprintf("tier%d", tier), func(t *testing.T) {
|
||||
got := false
|
||||
for i := 0; i < 500 && !got; i++ {
|
||||
if item := rollZoneIngredient(tier); item != nil {
|
||||
got = true
|
||||
if item.Tier != tier {
|
||||
t.Errorf("tier %d drop carried tier %d", tier, item.Tier)
|
||||
}
|
||||
if item.Value <= 0 {
|
||||
t.Errorf("%q dropped with value %d", item.Name, item.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("tier %d never dropped an ingredient in 500 rolls", tier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinLootLines_SkipsEmpties(t *testing.T) {
|
||||
if got := joinLootLines("", ""); got != "" {
|
||||
t.Errorf("all-empty = %q, want empty", got)
|
||||
}
|
||||
if got := joinLootLines("a", ""); got != "a" {
|
||||
t.Errorf("trailing empty = %q, want %q", got, "a")
|
||||
}
|
||||
if got := joinLootLines("", "b"); got != "b" {
|
||||
t.Errorf("leading empty = %q, want %q", got, "b")
|
||||
}
|
||||
if got := joinLootLines("a", "b"); got != "a\nb" {
|
||||
t.Errorf("both = %q, want %q", got, "a\nb")
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
type MasterworkDef struct {
|
||||
Slot EquipmentSlot
|
||||
Activity AdvActivityType
|
||||
SkillSource string // "mining", "fishing", "foraging"
|
||||
Tier int // 1-5, matches location tier
|
||||
SkillSource string // "mining", "fishing", "foraging"
|
||||
Tier int // 1-5, matches location tier
|
||||
Name string
|
||||
Description string // character sheet / trade listing
|
||||
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.005
|
||||
@@ -115,37 +115,10 @@ 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:
|
||||
@@ -191,15 +164,9 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
def := masterworkDefFor(loc.Activity, loc.Tier)
|
||||
if def == nil {
|
||||
return // no masterwork available for this activity+tier
|
||||
return // no masterwork available for this activity+tier (e.g. dungeon)
|
||||
}
|
||||
|
||||
// Roll for drop (chat level rare bonus applied additively)
|
||||
@@ -283,9 +250,8 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
|
||||
sb.WriteString("_This doesn't come from the shop._\n\n")
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Flavor text
|
||||
flavor := masterworkDropFlavorText(def.Activity, def.Tier)
|
||||
if flavor != "" {
|
||||
sb.WriteString(fmt.Sprintf("_%s_\n\n", flavor))
|
||||
}
|
||||
@@ -435,8 +401,18 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
|
||||
return p.SendDM(ctx.Sender, "Equip cancelled.")
|
||||
}
|
||||
|
||||
idx, ok := parseMenuIndex(reply, len(data.Items))
|
||||
if !ok {
|
||||
// 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) {
|
||||
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
|
||||
}
|
||||
|
||||
@@ -492,33 +468,8 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
|
||||
return p.SendDM(ctx.Sender, "Equip cancelled.")
|
||||
}
|
||||
|
||||
// Serialize against the sender's own !give: MasterworkGear/ArenaGear are
|
||||
// giftable (N4/E2), so the item captured at prompt time may have changed
|
||||
// hands before this confirmation. The lock makes gift-vs-confirm atomic,
|
||||
// and the re-load below refuses an item that is no longer ours — otherwise
|
||||
// equipping the captured copy would mint a duplicate the gift already
|
||||
// handed to the recipient.
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
selected := data.Item
|
||||
|
||||
inv, err := loadAdvInventory(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your inventory.")
|
||||
}
|
||||
stillOwned := false
|
||||
for _, it := range inv {
|
||||
if it.ID == selected.ID {
|
||||
stillOwned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !stillOwned {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** isn't in your inventory anymore. Equip cancelled.", selected.Name))
|
||||
}
|
||||
|
||||
equip, err := loadAdvEquipment(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load equipment.")
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,600 +0,0 @@
|
||||
package plugin
|
||||
|
||||
// Mischief Makers (M1) — delivery.
|
||||
//
|
||||
// The sibling of the ambient ticker: once a contract's window closes, the
|
||||
// monster has to actually find the target mid-run. runMischiefInterrupt is
|
||||
// modelled line-for-line on runHarvestInterrupt (pick enemy → ambush nick →
|
||||
// runZoneCombatRoster → close out), with two deliberate departures:
|
||||
//
|
||||
// - It never calls recordZoneKill and never advances zone state. The fight is
|
||||
// extrinsic to the dungeon: somebody bought it, the dungeon didn't send it.
|
||||
// Crediting it as a zone kill would let a buyer unlock a target's
|
||||
// RequiresKill resource gates for them.
|
||||
//
|
||||
// - It never marks anybody dead. runHarvestInterrupt's loss path perma-kills;
|
||||
// a *purchased* attack that lands while its victim is asleep must not be
|
||||
// able to delete their character. Mischief maims: floor at 1 HP,
|
||||
// force-extract, take a bite out of the un-banked coins.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// mischiefTickInterval — how often we look for contracts whose window has
|
||||
// closed. The window itself is the pacing; the tick just has to be fine-grained
|
||||
// enough that "about an hour" isn't a lie.
|
||||
const mischiefTickInterval = time.Minute
|
||||
|
||||
// mischiefDeliveryGrace — how long past its window a contract may sit in
|
||||
// `delivering` before the sweep calls it stranded. Comfortably longer than any
|
||||
// chain of auto-resolved fights takes, so a slow delivery is never swept out
|
||||
// from under itself (and the CAS in abandonStaleMischief is the backstop if it
|
||||
// somehow is).
|
||||
const mischiefDeliveryGrace = 15 * time.Minute
|
||||
|
||||
// mischiefTreasureWeight / mischiefRenownXP — the survival extras. Deliberately
|
||||
// modest: the purse is the reward, this is the garnish.
|
||||
const (
|
||||
mischiefTreasureWeight = 1.0
|
||||
mischiefEliteRenownXP = 40
|
||||
mischiefBossRenownXP = 120
|
||||
mischiefBossCacheSize = 2
|
||||
)
|
||||
|
||||
func (p *AdventurePlugin) mischiefTicker() {
|
||||
ticker := time.NewTicker(mischiefTickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.fireMischiefDeliveries(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireMischiefDeliveries walks contracts whose window has closed and either
|
||||
// delivers the monster or fizzles the contract.
|
||||
//
|
||||
// The gates mirror the ambient ticker's, for the same reason: a mid-run event
|
||||
// that talks over a live turn-based fight, or lands on top of the 06:00
|
||||
// briefing, reads as a bug even when it isn't. A blocked contract is NOT
|
||||
// fizzled — it simply waits for the next tick. The only thing that fizzles a
|
||||
// contract is the expedition ending, which is what the buyer was actually
|
||||
// betting against.
|
||||
func (p *AdventurePlugin) fireMischiefDeliveries(now time.Time) {
|
||||
if inAmbientQuietWindow(now) {
|
||||
return
|
||||
}
|
||||
p.sweepStaleMischief(now)
|
||||
for _, c := range loadMischiefDue(now) {
|
||||
contract := c
|
||||
p.fireOneMischiefDelivery(&contract)
|
||||
}
|
||||
}
|
||||
|
||||
// fireOneMischiefDelivery resolves a single due contract under the TARGET's lock.
|
||||
//
|
||||
// The lock is not optional. hasActiveCombatSession only sees the turn-based
|
||||
// engine; the target's own `!explore` / autopilot walk resolves its fights
|
||||
// inline (runHarvestInterrupt → runZoneCombatRoster) under advUserLock and
|
||||
// reports no session. Without taking that same lock, a delivery can run a second
|
||||
// combat against the same character sheet concurrently — two LoadDnDCharacter →
|
||||
// mutate → SaveDnDCharacter writers, last write wins, and a whole fight's HP cost
|
||||
// vanishes. The boredom ticker takes this lock for exactly this reason.
|
||||
//
|
||||
// Everything below the lock runs on the auto-resolve path, which takes no user
|
||||
// locks of its own, so there is nothing here to deadlock against.
|
||||
func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) {
|
||||
target := contract.TargetID
|
||||
|
||||
lock := p.advUserLock(target)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
// Re-read under the lock: whatever the due-sweep saw, the expedition may have
|
||||
// ended while we were queuing behind the target's own command.
|
||||
exp, err := getActiveExpedition(target)
|
||||
if err != nil || exp == nil || exp.Status != ExpeditionStatusActive {
|
||||
p.fizzleMischief(contract)
|
||||
return
|
||||
}
|
||||
// Same safety net the ambient ticker keeps: the expedition row can still
|
||||
// say 'active' after the player has functionally left the dungeon.
|
||||
if exp.RunID != "" {
|
||||
if run, _ := getZoneRun(exp.RunID); run == nil || !run.IsActive() {
|
||||
p.fizzleMischief(contract)
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasActiveCombatSession(target) {
|
||||
return // they're mid-fight; the monster can wait a minute
|
||||
}
|
||||
if !claimMischiefForDelivery(contract.ID) {
|
||||
return // another tick (or another process) already has it
|
||||
}
|
||||
// Re-read AFTER the claim, never before it. The row we were handed came from
|
||||
// the due-sweep, and the window's commands (bless, escalate) keep writing to an
|
||||
// open contract right up to the moment the claim closes it. A ward bought in
|
||||
// that gap would be paid for and never applied; an escalation would be paid for
|
||||
// and deliver the *old* tier's monster. The claim is the fence — everything the
|
||||
// fight is built from has to be read on this side of it.
|
||||
if fresh, err := mischiefContractByID(contract.ID); err == nil && fresh != nil {
|
||||
contract = fresh
|
||||
}
|
||||
if err := p.deliverMischief(contract, exp); err != nil {
|
||||
slog.Error("mischief: delivery failed",
|
||||
"contract", contract.ID, "target", target, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepStaleMischief releases contracts stranded in `delivering` — the process
|
||||
// died between the claim and the close-out (a restart mid-fight, a panic). The
|
||||
// row would otherwise be immortal: the target can never be targeted again, and
|
||||
// the buyer's money never comes back.
|
||||
//
|
||||
// Everyone who paid is made whole in full rather than rake-charged. A stranded
|
||||
// delivery is our crash, not a bet they lost — and the escalator paid too, so the
|
||||
// refund splits on mischiefOutlay.
|
||||
//
|
||||
// The ward comes back off the sheet here as well. The crash is exactly the case
|
||||
// runMischiefInterrupt's `defer clearMischiefBlessings` cannot cover, and a ward
|
||||
// left behind is a permanent free cushion on the target until something else
|
||||
// happens to reset TempHP.
|
||||
func (p *AdventurePlugin) sweepStaleMischief(now time.Time) {
|
||||
for _, c := range loadStaleMischiefDeliveries(now.Add(-mischiefDeliveryGrace)) {
|
||||
contract := c
|
||||
if !abandonStaleMischief(contract.ID) {
|
||||
continue
|
||||
}
|
||||
p.clearStrandedMischiefWard(&contract)
|
||||
|
||||
buyerPaid, escPaid := mischiefOutlay(&contract)
|
||||
p.euro.Credit(contract.BuyerID, float64(buyerPaid), "mischief_refund_stranded")
|
||||
p.SendDM(contract.BuyerID, fmt.Sprintf(
|
||||
"😾 Your **%s** never reached **%s** — something went wrong on our end. Fully refunded: %s.",
|
||||
contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(buyerPaid)))
|
||||
if escPaid > 0 {
|
||||
p.euro.Credit(contract.EscalatedBy, float64(escPaid), "mischief_refund_stranded")
|
||||
p.SendDM(contract.EscalatedBy, fmt.Sprintf(
|
||||
"😾 The **%s** you paid to upgrade never reached **%s** — something went wrong on our end. "+
|
||||
"Fully refunded: %s.",
|
||||
contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(escPaid)))
|
||||
}
|
||||
slog.Warn("mischief: swept stranded delivery",
|
||||
"contract", contract.ID, "target", contract.TargetID)
|
||||
}
|
||||
}
|
||||
|
||||
// clearStrandedMischiefWard takes back a ward whose delivery died before it could
|
||||
// clear its own. The amount is re-derived exactly as applyMischiefBlessings
|
||||
// derived it, off the target's current MaxHP.
|
||||
//
|
||||
// If the crash landed BEFORE the ward was ever applied, this over-subtracts into
|
||||
// a well-rested cushion the target had of their own. That window is a handful of
|
||||
// DB reads wide against a whole fight, clearMischiefBlessings floors at 0, and the
|
||||
// alternative — leaving a stranded ward on the sheet forever — is the strictly
|
||||
// worse failure.
|
||||
func (p *AdventurePlugin) clearStrandedMischiefWard(c *mischiefContract) {
|
||||
if c.BlessingCount <= 0 {
|
||||
return
|
||||
}
|
||||
dc, err := LoadDnDCharacter(c.TargetID)
|
||||
if err != nil || dc == nil {
|
||||
return
|
||||
}
|
||||
if ward := mischiefBlessingTempHP(dc.HPMax, c.BlessingCount); ward > 0 {
|
||||
p.clearMischiefBlessings(c.TargetID, ward)
|
||||
}
|
||||
}
|
||||
|
||||
// fizzleMischief refunds everyone who put money on the contract (minus the rake)
|
||||
// when the monster arrives to an empty dungeon. The CAS is what makes the refund
|
||||
// safe: a contract that was simultaneously claimed for delivery cannot also be
|
||||
// refunded here.
|
||||
//
|
||||
// The buyer and the escalator are refunded SEPARATELY, off mischiefOutlay. They
|
||||
// are two people who each parted with their own money, and c.Paid is the sum.
|
||||
func (p *AdventurePlugin) fizzleMischief(c *mischiefContract) {
|
||||
if !fizzleMischiefContract(c.ID) {
|
||||
return
|
||||
}
|
||||
buyerPaid, escPaid := mischiefOutlay(c)
|
||||
|
||||
refund, rake := p.rakedMischiefRefund(c.BuyerID, buyerPaid, "mischief_fizzle_refund")
|
||||
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||
"😾 Your **%s** got to the dungeon and found nobody home — **%s** was already out of there.\n"+
|
||||
"You're refunded %s. The other %s stays with the town, for its trouble.",
|
||||
c.Tier, p.DisplayName(c.TargetID), fmtEuro(refund), fmtEuro(rake)))
|
||||
|
||||
if escPaid > 0 {
|
||||
escRefund, escRake := p.rakedMischiefRefund(c.EscalatedBy, escPaid, "mischief_fizzle_refund")
|
||||
p.SendDM(c.EscalatedBy, fmt.Sprintf(
|
||||
"😾 The **%s** you paid to upgrade found nobody home — **%s** was already out of there.\n"+
|
||||
"You're refunded %s. The other %s stays with the town, for its trouble.",
|
||||
c.Tier, p.DisplayName(c.TargetID), fmtEuro(escRefund), fmtEuro(escRake)))
|
||||
refund += escRefund
|
||||
}
|
||||
emitMischiefFizzledNews(c, refund)
|
||||
}
|
||||
|
||||
// rakedMischiefRefund gives one stakeholder their stake back minus the fizzle
|
||||
// rake, and sinks the rake against THEIR name. Returns what they got back and
|
||||
// what the town kept.
|
||||
func (p *AdventurePlugin) rakedMischiefRefund(who id.UserID, paid int, reason string) (refund, rake int) {
|
||||
if paid <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
refund = int(float64(paid) * mischiefFizzleRefund)
|
||||
rake = paid - refund
|
||||
if refund > 0 {
|
||||
p.euro.Credit(who, float64(refund), reason)
|
||||
}
|
||||
if rake > 0 {
|
||||
communityPotAdd(rake)
|
||||
trackTaxPaid(who, rake)
|
||||
}
|
||||
return refund, rake
|
||||
}
|
||||
|
||||
// deliverMischief runs the purchased fight and closes it out. By the time this
|
||||
// is called the contract is claimed, so it resolves exactly once.
|
||||
func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition) error {
|
||||
tier, ok := mischiefTierByKey(c.Tier)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown mischief tier %q", c.Tier)
|
||||
}
|
||||
target := c.TargetID
|
||||
|
||||
dndChar, err := LoadDnDCharacter(target)
|
||||
if err != nil || dndChar == nil {
|
||||
// The target evaporated between claim and delivery. Nothing to attack —
|
||||
// treat it as a fizzle so the buyer isn't charged for a no-show.
|
||||
p.refundClaimedMischief(c, "target has no character")
|
||||
return nil
|
||||
}
|
||||
|
||||
// The attacker comes from the target's own bracket, not the dungeon they are
|
||||
// standing in. See mischiefBracketZones.
|
||||
bracket := mischiefBracketZone(dndChar.Level)
|
||||
rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
|
||||
chain, ambush := mischiefMonsters(tier.Key, bracket, rng)
|
||||
// Every link, not just the first: a zone-pool entry with no bestiary row
|
||||
// yields a zero-value template, and a nameless 0-HP monster as fight 2 of a
|
||||
// mob would read as the attack inexplicably giving up halfway.
|
||||
if len(chain) == 0 {
|
||||
p.refundClaimedMischief(c, "no monster available")
|
||||
return nil
|
||||
}
|
||||
for _, m := range chain {
|
||||
if m.Name == "" {
|
||||
p.refundClaimedMischief(c, "bestiary miss in the "+bracket.Display+" pool")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
narration, monsterName, survived := p.runMischiefInterrupt(
|
||||
target, exp, bracket, chain, ambush, c.BlessingCount)
|
||||
|
||||
if survived {
|
||||
p.resolveMischiefSurvived(c, tier, exp, bracket, monsterName, narration)
|
||||
} else {
|
||||
p.resolveMischiefDowned(c, exp, monsterName, narration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refundClaimedMischief unwinds a contract that was already claimed for delivery
|
||||
// but couldn't be staged. Everyone who paid gets everything back — this is our
|
||||
// fault, not a fizzle they gambled on. The escalator is a payer too; see
|
||||
// mischiefOutlay.
|
||||
func (p *AdventurePlugin) refundClaimedMischief(c *mischiefContract, reason string) {
|
||||
resolveMischiefContract(c.ID, mischiefStatusFizzled, mischiefStatusFizzled)
|
||||
buyerPaid, escPaid := mischiefOutlay(c)
|
||||
p.euro.Credit(c.BuyerID, float64(buyerPaid), "mischief_refund_unstageable")
|
||||
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||
"😾 Your **%s** never made it out of the gate. Fully refunded: %s.", c.Tier, fmtEuro(buyerPaid)))
|
||||
if escPaid > 0 {
|
||||
p.euro.Credit(c.EscalatedBy, float64(escPaid), "mischief_refund_unstageable")
|
||||
p.SendDM(c.EscalatedBy, fmt.Sprintf(
|
||||
"😾 The **%s** you paid to upgrade never made it out of the gate. Fully refunded: %s.",
|
||||
c.Tier, fmtEuro(escPaid)))
|
||||
}
|
||||
slog.Warn("mischief: contract unstageable", "contract", c.ID, "reason", reason)
|
||||
}
|
||||
|
||||
// runMischiefInterrupt fights the whole chain and reports whether the target was
|
||||
// still standing at the end.
|
||||
//
|
||||
// Survival is read off the target's HP, not off PlayerWon. The engine's timeout
|
||||
// is a retreat, not a lethal blow — a target who ran out the clock with HP left
|
||||
// held the thing off, and a bought monster that merely *outlasted* them has not
|
||||
// earned a maiming. The chain continues while they stand: a mob is three
|
||||
// attackers, and beating the first two doesn't mean the third goes home.
|
||||
func (p *AdventurePlugin) runMischiefInterrupt(
|
||||
target id.UserID,
|
||||
exp *Expedition,
|
||||
bracket ZoneDefinition,
|
||||
chain []DnDMonsterTemplate,
|
||||
ambush bool,
|
||||
blessings int,
|
||||
) (narration, monsterName string, survived bool) {
|
||||
var b strings.Builder
|
||||
last := chain[len(chain)-1].Name
|
||||
|
||||
if ward := p.applyMischiefBlessings(target, blessings); ward > 0 {
|
||||
b.WriteString(fmt.Sprintf(
|
||||
"_The town's wards hold — %d of them, worth %d HP of cushion._\n", blessings, ward))
|
||||
defer p.clearMischiefBlessings(target, ward)
|
||||
}
|
||||
|
||||
for i, monster := range chain {
|
||||
// The ambush is the attacker's privilege — it was lying in wait, and the
|
||||
// target had no reason to expect it. Same one-free-swing approximation the
|
||||
// harvest interrupt uses, with the same wounded-entrant clamp so it can't
|
||||
// pre-empt the fight outright.
|
||||
if ambush && i == 0 {
|
||||
if dc, _ := LoadDnDCharacter(target); dc != nil {
|
||||
nick := clampSurpriseNick(
|
||||
surpriseRoundNick(monster, int(bracket.Tier)), dc.HPCurrent, dc.HPMax)
|
||||
if nick > 0 {
|
||||
dc.HPCurrent -= nick
|
||||
_ = SaveDnDCharacter(dc)
|
||||
b.WriteString(fmt.Sprintf(
|
||||
"_It was waiting for you. **%s** opens with a free swing — %d HP._\n",
|
||||
monster.Name, nick))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preHP, _ := dndHPSnapshot(target)
|
||||
pres, _, err := p.runZoneCombatRoster(
|
||||
fightRoster(target), monster, int(bracket.Tier), dungeonCombatPhases, exp.DMMood)
|
||||
if err != nil {
|
||||
// Mid-chain build failure. The target keeps whatever HP they had; treat
|
||||
// them as having survived rather than maiming them on our bug.
|
||||
slog.Error("mischief: combat error", "target", target, "err", err)
|
||||
b.WriteString(fmt.Sprintf("_(The %s never quite arrives.)_\n", monster.Name))
|
||||
return b.String(), last, true
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(target)
|
||||
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||
switch {
|
||||
case pres.Seats[0].PlayerWon:
|
||||
b.WriteString(fmt.Sprintf("✅ **%s** down — %d→%d / %d HP.\n",
|
||||
monster.Name, preHP, postHP, maxHP))
|
||||
case postHP > 0:
|
||||
// The engine's timeout: they held it off without killing it. That still
|
||||
// pays — but say so plainly, or a stalemate reads as a clean kill and the
|
||||
// player wonders why the thing is still following them.
|
||||
b.WriteString(fmt.Sprintf("⏳ **%s** breaks off, still breathing. You're at %d / %d HP.\n",
|
||||
monster.Name, postHP, maxHP))
|
||||
}
|
||||
if postHP <= 0 {
|
||||
return b.String(), monster.Name, false
|
||||
}
|
||||
if i < len(chain)-1 {
|
||||
b.WriteString("_And it isn't alone._\n")
|
||||
}
|
||||
}
|
||||
return b.String(), last, true
|
||||
}
|
||||
|
||||
// applyMischiefBlessings turns the room's wards into temp HP on the target's
|
||||
// sheet for the duration of the delivery, and returns the cushion granted.
|
||||
//
|
||||
// TempHP is the well-rested mechanism (applyDnDHPScaling layers it above MaxHP
|
||||
// for the fight, and persistDnDHPAfterCombat clamps back down to MaxHP after), so
|
||||
// a ward is a damage sponge and nothing more — it can't leave the target at more
|
||||
// HP than they started with.
|
||||
//
|
||||
// It ADDS to whatever TempHP is already there rather than overwriting: a target
|
||||
// who long-rested at a T3 home before setting out is carrying a cushion of their
|
||||
// own, and a ward bought to help them must not silently delete it. clearMischiefBlessings
|
||||
// subtracts exactly what we added, which is why this returns the amount.
|
||||
//
|
||||
// The cushion refreshes for each link of a chain, because TempHP is a sheet field
|
||||
// and applyDnDHPScaling re-reads it per fight. That only ever matters for the mob
|
||||
// tier (the sole multi-fight chain), which the M0 sweep priced at 98-100% survival
|
||||
// as pure theatre anyway. The tiers where a ward decides anything — elite and boss —
|
||||
// are single fights, so what the room buys there is exactly one sponge.
|
||||
func (p *AdventurePlugin) applyMischiefBlessings(target id.UserID, blessings int) int {
|
||||
if blessings <= 0 {
|
||||
return 0
|
||||
}
|
||||
c, err := LoadDnDCharacter(target)
|
||||
if err != nil || c == nil {
|
||||
return 0
|
||||
}
|
||||
ward := mischiefBlessingTempHP(c.HPMax, blessings)
|
||||
if ward <= 0 {
|
||||
return 0
|
||||
}
|
||||
c.TempHP += ward
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return 0
|
||||
}
|
||||
return ward
|
||||
}
|
||||
|
||||
// clearMischiefBlessings takes the ward back off the sheet once the delivery is
|
||||
// over — the blessing was bought for THIS fight, not for the rest of the run.
|
||||
// Subtracts what we added, so a pre-existing well-rested cushion survives intact.
|
||||
func (p *AdventurePlugin) clearMischiefBlessings(target id.UserID, ward int) {
|
||||
c, err := LoadDnDCharacter(target)
|
||||
if err != nil || c == nil {
|
||||
return
|
||||
}
|
||||
if c.TempHP -= ward; c.TempHP < 0 {
|
||||
c.TempHP = 0
|
||||
}
|
||||
_ = SaveDnDCharacter(c)
|
||||
}
|
||||
|
||||
// floorMischiefRoster raises every seat the delivery put on the floor back to
|
||||
// 1 HP. It runs on BOTH outcomes, and that is not belt-and-braces: the leader
|
||||
// can win a fight their friend went down in, and a mischief delivery
|
||||
// deliberately skips closeOutZoneWin/closeOutZoneLoss (which is what normally
|
||||
// marks a 0-HP seat dead). Without this, a dropped party member is left alive at
|
||||
// 0 HP — a state nothing else in the game produces, and one the gates that read
|
||||
// `HPCurrent <= 0` (expedition autorun, for one) treat as broken rather than
|
||||
// dead. Nobody dies for money; nobody gets stuck at zero for it either.
|
||||
func (p *AdventurePlugin) floorMischiefRoster(target id.UserID) {
|
||||
for _, uid := range fightRoster(target) {
|
||||
if isCompanionSeat(uid) {
|
||||
continue // he takes no wounds home; he has no rows to floor
|
||||
}
|
||||
floorHPAtOne(uid)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveMischiefSurvived pays the target and unseals the buyer.
|
||||
//
|
||||
// The purse is a percentage of the base fee — never of what the buyer paid — so
|
||||
// it is always strictly less than the buyer's outlay. See mischiefPurse.
|
||||
func (p *AdventurePlugin) resolveMischiefSurvived(
|
||||
c *mischiefContract, tier mischiefTier, exp *Expedition, bracket ZoneDefinition,
|
||||
monsterName, narration string,
|
||||
) {
|
||||
// The leader lived; a friend of theirs may not have. See floorMischiefRoster.
|
||||
p.floorMischiefRoster(c.TargetID)
|
||||
|
||||
purse := mischiefPurse(c.Fee, tier.PayoutPct)
|
||||
p.euro.Credit(c.TargetID, float64(purse), "mischief_survived")
|
||||
|
||||
// Everything spent on the contract that isn't the purse — the rake, plus the
|
||||
// whole sign surcharge — is a sink. That is what makes the payout cap bite. It
|
||||
// is booked against the buyer AND the escalator in proportion to what each of
|
||||
// them actually put in; c.Paid is the sum of the two.
|
||||
if rake := c.Paid - purse; rake > 0 {
|
||||
communityPotAdd(rake)
|
||||
trackMischiefSink(c, rake)
|
||||
}
|
||||
|
||||
// Extras by tier. Treasure rolls against the zone they're actually in — the
|
||||
// loot is scavenged off a corpse in that dungeon, wherever the thing came from.
|
||||
var extras []string
|
||||
switch tier.Key {
|
||||
case "mob":
|
||||
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||
case "elite":
|
||||
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||
if before, after, err := addRenownXP(c.TargetID, mischiefEliteRenownXP); err == nil {
|
||||
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
|
||||
extras = append(extras, "The story of it is already going round town.")
|
||||
}
|
||||
case "boss":
|
||||
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||
if before, after, err := addRenownXP(c.TargetID, mischiefBossRenownXP); err == nil {
|
||||
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
|
||||
extras = append(extras, "People are going to be telling this one for a while.")
|
||||
}
|
||||
for _, item := range consumableCache(int(bracket.Tier), mischiefBossCacheSize) {
|
||||
it := item
|
||||
p.grantZoneItem(c.TargetID, &it, "🧪")
|
||||
}
|
||||
}
|
||||
|
||||
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||
|
||||
var dm strings.Builder
|
||||
dm.WriteString("😈 **Somebody paid for that.**\n\n")
|
||||
dm.WriteString(narration)
|
||||
dm.WriteString(fmt.Sprintf(
|
||||
"\n🛡️ You're still standing, and the coin was never really theirs. **%s** is yours.\n",
|
||||
fmtEuro(purse)))
|
||||
dm.WriteString(fmt.Sprintf("It was **%s** who paid to have you killed. Do with that what you like.%s",
|
||||
p.DisplayName(c.BuyerID), p.mischiefEscalatorNote(c)))
|
||||
for _, e := range extras {
|
||||
dm.WriteString("\n_" + e + "_")
|
||||
}
|
||||
p.SendDM(c.TargetID, dm.String())
|
||||
|
||||
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||
"🛡️ **%s walked away from your %s.** They keep %s of your money, and the town knows your name now.",
|
||||
p.DisplayName(c.TargetID), c.Tier, fmtEuro(purse)))
|
||||
if c.EscalatedBy != "" && c.EscalatedBy != c.BuyerID {
|
||||
p.SendDM(c.EscalatedBy, fmt.Sprintf(
|
||||
"🛡️ **%s** walked away from the **%s** you paid to upgrade — and your money went into their purse.\n"+
|
||||
"_The town knows you piled on. That was the deal._",
|
||||
p.DisplayName(c.TargetID), c.Tier))
|
||||
}
|
||||
|
||||
p.announceMischiefSurvived(c, monsterName, purse)
|
||||
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeSurvived, purse)
|
||||
}
|
||||
|
||||
// resolveMischiefDowned maims the target: HP floored at 1, expedition
|
||||
// force-extracted, un-banked coins docked. Nobody dies for money.
|
||||
//
|
||||
// The whole fee goes to the community pot — never back to the buyer. A
|
||||
// successful hit buys glory, not a rebate; refunding it would make repeat
|
||||
// contracts free and turn the cap into a rounding error.
|
||||
func (p *AdventurePlugin) resolveMischiefDowned(
|
||||
c *mischiefContract, exp *Expedition, monsterName, narration string,
|
||||
) {
|
||||
// Every seat, not just the leader: a party that went down with them went down
|
||||
// to a bought monster too, and the no-perma-death rule is about the purchase,
|
||||
// not about who was holding the torch. Must run BEFORE the extract, which
|
||||
// releases the party and would leave us nobody to floor.
|
||||
p.floorMischiefRoster(c.TargetID)
|
||||
|
||||
// forcedExtractExpedition retires the region runs and releases the party itself;
|
||||
// only the zone run has to be closed here.
|
||||
_ = abandonZoneRun(c.TargetID)
|
||||
_, tax, err := forcedExtractExpedition(exp.ID, "mischief contract")
|
||||
if err != nil {
|
||||
slog.Warn("mischief: force extract failed", "expedition", exp.ID, "err", err)
|
||||
}
|
||||
// The un-banked-coin loss. forcedExtractExpedition computes the 20% cut and
|
||||
// leaves the debit to the caller; for a maiming, this is the caller.
|
||||
if tax > 0 {
|
||||
p.euro.Debit(c.TargetID, float64(tax), "mischief_downed_loss")
|
||||
}
|
||||
|
||||
communityPotAdd(c.Paid)
|
||||
trackMischiefSink(c, c.Paid)
|
||||
|
||||
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
|
||||
|
||||
var dm strings.Builder
|
||||
dm.WriteString("😈 **Somebody paid for that.**\n\n")
|
||||
dm.WriteString(narration)
|
||||
dm.WriteString(fmt.Sprintf(
|
||||
"\n💀 **%s** put you down. You wake up on a cart headed home — alive, which is more than the contract asked for.\n",
|
||||
monsterName))
|
||||
dm.WriteString("Your expedition is over.")
|
||||
if tax > 0 {
|
||||
dm.WriteString(fmt.Sprintf(" Whatever you hadn't banked yet is gone — %s of it.", fmtEuro(tax)))
|
||||
}
|
||||
if c.Signed {
|
||||
dm.WriteString(fmt.Sprintf("\n\nThe contract was signed: **%s** paid for it.", p.DisplayName(c.BuyerID)))
|
||||
} else {
|
||||
dm.WriteString("\n\nNobody's saying who paid.")
|
||||
}
|
||||
p.SendDM(c.TargetID, dm.String())
|
||||
|
||||
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||
"💀 Your **%s** found **%s** and put them on the floor. Their expedition is over.\n"+
|
||||
"_You get nothing back — %s went to the community pot. You did this for the story._",
|
||||
c.Tier, p.DisplayName(c.TargetID), fmtEuro(c.Paid)))
|
||||
if c.EscalatedBy != "" && c.EscalatedBy != c.BuyerID {
|
||||
// They stay anonymous: only a survival unseals. Their money is gone either
|
||||
// way — that is what makes the survival unseal a real risk to have taken.
|
||||
p.SendDM(c.EscalatedBy, fmt.Sprintf(
|
||||
"💀 The **%s** you paid to upgrade found **%s** and put them down. Nobody knows it was you.",
|
||||
c.Tier, p.DisplayName(c.TargetID)))
|
||||
}
|
||||
|
||||
p.announceMischiefDowned(c, monsterName)
|
||||
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeDowned, 0)
|
||||
}
|
||||
@@ -1,929 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newMischiefTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db.Close()
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func seedContract(t *testing.T, buyer, target id.UserID, tier string, signed bool) *mischiefContract {
|
||||
t.Helper()
|
||||
td, ok := mischiefTierByKey(tier)
|
||||
if !ok {
|
||||
t.Fatalf("unknown tier %q", tier)
|
||||
}
|
||||
paid := td.Fee
|
||||
if signed {
|
||||
paid = mischiefSignedFee(td.Fee)
|
||||
}
|
||||
c := &mischiefContract{
|
||||
ID: uuid.NewString(),
|
||||
BuyerID: buyer,
|
||||
TargetID: target,
|
||||
Tier: td.Key,
|
||||
Fee: td.Fee,
|
||||
Paid: paid,
|
||||
Status: mischiefStatusOpen,
|
||||
Signed: signed,
|
||||
Source: "matrix",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
|
||||
}
|
||||
if err := insertMischiefContract(c); err != nil {
|
||||
t.Fatalf("seed contract (%s → %s): %v", buyer, target, err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// The anti-collusion invariant, and the only reason no danger multiplier is
|
||||
// needed: whatever the tier and whether or not the buyer signed, the target's
|
||||
// survival purse is strictly less than what the buyer parted with. Two players
|
||||
// who try to move money this way lose to !baltransfer, which is free.
|
||||
func TestMischiefPurseNeverExceedsOutlay(t *testing.T) {
|
||||
for _, tier := range mischiefTiers {
|
||||
purse := mischiefPurse(tier.Fee, tier.PayoutPct)
|
||||
for _, paid := range []int{tier.Fee, mischiefSignedFee(tier.Fee)} {
|
||||
if purse >= paid {
|
||||
t.Errorf("%s: purse %d >= buyer outlay %d — collusion beats !baltransfer",
|
||||
tier.Key, purse, paid)
|
||||
}
|
||||
}
|
||||
if tier.PayoutPct > 0.75 {
|
||||
t.Errorf("%s: payout %.2f exceeds the 75%% cap", tier.Key, tier.PayoutPct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cap is enforced in code, not just in the table — an escalation (M2) raises
|
||||
// the payout basis, and nothing it can do may push the purse past 75%.
|
||||
func TestMischiefPurseHardCap(t *testing.T) {
|
||||
if got := mischiefPurse(1000, 0.95); got != 750 {
|
||||
t.Fatalf("mischiefPurse(1000, 0.95) = %d, want 750 (capped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefSignedFee(t *testing.T) {
|
||||
// The tier table's signed prices, as priced in M0.
|
||||
want := map[string]int{"grunt": 50, "mob": 125, "elite": 438, "boss": 1500}
|
||||
for _, tier := range mischiefTiers {
|
||||
if got := mischiefSignedFee(tier.Fee); got != want[tier.Key] {
|
||||
t.Errorf("%s signed fee = %d, want %d", tier.Key, got, want[tier.Key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly one of delivery and fizzle may claim a contract, however many ticks
|
||||
// race for it. Without this a restart mid-delivery could both maim the target
|
||||
// and refund the buyer.
|
||||
func TestMischiefClaimIsExactlyOnce(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
c := seedContract(t, "@buyer:x", "@target:x", "elite", false)
|
||||
|
||||
if !claimMischiefForDelivery(c.ID) {
|
||||
t.Fatal("first claim should win")
|
||||
}
|
||||
if claimMischiefForDelivery(c.ID) {
|
||||
t.Error("second claim won — a contract could be delivered twice")
|
||||
}
|
||||
if fizzleMischiefContract(c.ID) {
|
||||
t.Error("fizzle won after the delivery claim — buyer would be refunded a hit that landed")
|
||||
}
|
||||
|
||||
c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false)
|
||||
if !fizzleMischiefContract(c2.ID) {
|
||||
t.Fatal("first fizzle should win")
|
||||
}
|
||||
if claimMischiefForDelivery(c2.ID) {
|
||||
t.Error("delivery claimed a fizzled contract — buyer would pay twice")
|
||||
}
|
||||
}
|
||||
|
||||
// Only a closed window is due, and a resolved contract never comes back.
|
||||
func TestMischiefDueWindow(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
c := seedContract(t, "@buyer:x", "@target:x", "mob", false)
|
||||
|
||||
if due := loadMischiefDue(now); len(due) != 0 {
|
||||
t.Fatalf("contract is due before its window closed: %d", len(due))
|
||||
}
|
||||
due := loadMischiefDue(now.Add(mischiefWindow + time.Minute))
|
||||
if len(due) != 1 || due[0].ID != c.ID {
|
||||
t.Fatalf("want the contract due after its window; got %d", len(due))
|
||||
}
|
||||
|
||||
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||
if due := loadMischiefDue(now.Add(2 * mischiefWindow)); len(due) != 0 {
|
||||
t.Error("a resolved contract came back around")
|
||||
}
|
||||
}
|
||||
|
||||
// One live contract per target: two monsters converging on the same person is a
|
||||
// mugging, not mischief.
|
||||
func TestMischiefOneLiveContractPerTarget(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
target := id.UserID("@target:x")
|
||||
if liveMischiefForTarget(target) != nil {
|
||||
t.Fatal("no contract seeded, but one is live")
|
||||
}
|
||||
c := seedContract(t, "@buyer:x", target, "grunt", false)
|
||||
if live := liveMischiefForTarget(target); live == nil || live.ID != c.ID {
|
||||
t.Fatal("open contract should be live")
|
||||
}
|
||||
// A claimed-but-unresolved contract still blocks — the monster is en route.
|
||||
claimMischiefForDelivery(c.ID)
|
||||
if liveMischiefForTarget(target) == nil {
|
||||
t.Error("a delivering contract should still block a second one")
|
||||
}
|
||||
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
|
||||
if liveMischiefForTarget(target) != nil {
|
||||
t.Error("a resolved contract still reads as live")
|
||||
}
|
||||
}
|
||||
|
||||
// The post-resolution breather. Also the regression guard for the modernc
|
||||
// datetime hazard: resolved_at round-trips through the driver's own encoding
|
||||
// (bound Go time in, time.Time out), so the row has to be a real one — a
|
||||
// hand-written stamp in some other format would compare lexicographically and
|
||||
// silently never expire.
|
||||
func TestMischiefTargetCooldown(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
target := id.UserID("@target:x")
|
||||
now := time.Now().UTC()
|
||||
|
||||
if cooling, _ := mischiefTargetCoolingDown(target, now); cooling {
|
||||
t.Fatal("a target with no history is cooling down")
|
||||
}
|
||||
c := seedContract(t, "@buyer:x", target, "grunt", false)
|
||||
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||
|
||||
cooling, wait := mischiefTargetCoolingDown(target, now)
|
||||
if !cooling {
|
||||
t.Fatal("a just-resolved target should be cooling down")
|
||||
}
|
||||
// resolved_at is stamped a hair after `now`, so allow a second of slack.
|
||||
if wait <= 0 || wait > mischiefTargetCooldown+time.Second {
|
||||
t.Errorf("wait = %s, want within (0, %s]", wait, mischiefTargetCooldown)
|
||||
}
|
||||
if cooling, _ := mischiefTargetCoolingDown(target, now.Add(mischiefTargetCooldown+time.Minute)); cooling {
|
||||
t.Error("still cooling down after the cooldown elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limits: contracts per buyer per day, and boss-tier per target per week.
|
||||
// Fizzled contracts count against the buyer — the limit is on how often you may
|
||||
// point a monster at someone, not on how often it connects.
|
||||
func TestMischiefRateLimits(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
buyer := id.UserID("@buyer:x")
|
||||
now := time.Now().UTC()
|
||||
dayAgo := now.Add(-24 * time.Hour)
|
||||
|
||||
if n := mischiefBuyerCountSince(buyer, dayAgo); n != 0 {
|
||||
t.Fatalf("fresh buyer count = %d, want 0", n)
|
||||
}
|
||||
c1 := seedContract(t, buyer, "@a:x", "grunt", false)
|
||||
seedContract(t, buyer, "@b:x", "mob", false)
|
||||
fizzleMischiefContract(c1.ID)
|
||||
if n := mischiefBuyerCountSince(buyer, dayAgo); n != mischiefBuyerDailyCap {
|
||||
t.Errorf("buyer count = %d, want %d (a fizzle still counts)", n, mischiefBuyerDailyCap)
|
||||
}
|
||||
|
||||
target := id.UserID("@whale:x")
|
||||
weekAgo := now.Add(-mischiefBossPerTargetWindow)
|
||||
if n := mischiefBossCountSince(target, weekAgo); n != 0 {
|
||||
t.Fatalf("fresh boss count = %d, want 0", n)
|
||||
}
|
||||
elite := seedContract(t, "@other:x", target, "elite", false)
|
||||
if n := mischiefBossCountSince(target, weekAgo); n != 0 {
|
||||
t.Error("an elite contract counted against the boss-per-week limit")
|
||||
}
|
||||
// Resolve it first: only one contract may be live against a target at a time,
|
||||
// and the database enforces that (idx_mischief_one_live_per_target).
|
||||
resolveMischiefContract(elite.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||
seedContract(t, "@other:x", target, "boss", false)
|
||||
if n := mischiefBossCountSince(target, weekAgo); n != 1 {
|
||||
t.Errorf("boss count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The database, not a read-then-write check, is what stops two buyers racing a
|
||||
// monster at the same target. The placement path holds only the buyer's lock, so
|
||||
// without this index both inserts would land and the target would be hit twice.
|
||||
func TestMischiefSecondLiveContractIsRejected(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
target := id.UserID("@target:x")
|
||||
seedContract(t, "@buyer1:x", target, "grunt", false)
|
||||
|
||||
second := &mischiefContract{
|
||||
ID: uuid.NewString(), BuyerID: "@buyer2:x", TargetID: target,
|
||||
Tier: "elite", Fee: 350, Paid: 350, Status: mischiefStatusOpen,
|
||||
Source: "matrix", CreatedAt: time.Now().UTC(),
|
||||
WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
|
||||
}
|
||||
if err := insertMischiefContract(second); err == nil {
|
||||
t.Fatal("a second live contract was accepted — the target can be hit twice at once")
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery that dies mid-flight (restart, panic) must not strand the contract:
|
||||
// the row would block the target forever and the buyer's money would be gone.
|
||||
func TestMischiefStaleDeliverySweep(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
c := seedContract(t, "@buyer:x", "@target:x", "boss", false)
|
||||
if !claimMischiefForDelivery(c.ID) {
|
||||
t.Fatal("claim should win")
|
||||
}
|
||||
|
||||
// Inside the grace period a slow delivery is left alone.
|
||||
if stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow)); len(stale) != 0 {
|
||||
t.Errorf("a delivery still inside its grace window was called stranded: %d", len(stale))
|
||||
}
|
||||
|
||||
stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow + mischiefDeliveryGrace + time.Minute))
|
||||
if len(stale) != 1 || stale[0].ID != c.ID {
|
||||
t.Fatalf("stranded delivery not found: %d", len(stale))
|
||||
}
|
||||
if !abandonStaleMischief(c.ID) {
|
||||
t.Fatal("abandon should win on a delivering row")
|
||||
}
|
||||
if abandonStaleMischief(c.ID) {
|
||||
t.Error("abandon won twice — the buyer would be refunded twice")
|
||||
}
|
||||
// And the target is free again.
|
||||
if liveMischiefForTarget("@target:x") != nil {
|
||||
t.Error("a swept contract still blocks the target")
|
||||
}
|
||||
}
|
||||
|
||||
// The monster is priced against the TARGET's bracket, not the dungeon they are
|
||||
// standing in — that is what the M0 fee table measured.
|
||||
func TestMischiefBracketZones(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
level int
|
||||
tier ZoneTier
|
||||
}{{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3}, {13, 4}, {17, 4}, {18, 5}, {20, 5}} {
|
||||
zone := mischiefBracketZone(tc.level)
|
||||
if zone.Tier != tc.tier {
|
||||
t.Errorf("level %d draws from tier %d, want %d", tc.level, zone.Tier, tc.tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The fight shapes the fee table was priced against. Changing any of these
|
||||
// invalidates the pricing, so they are pinned.
|
||||
func TestMischiefMonsterChains(t *testing.T) {
|
||||
zone := mischiefBracketZone(10) // tier 3
|
||||
rng := rand.New(rand.NewPCG(0x6d69, 1))
|
||||
|
||||
for _, tc := range []struct {
|
||||
tier string
|
||||
wantLen int
|
||||
wantAmbush bool
|
||||
}{
|
||||
{"grunt", 1, false},
|
||||
{"mob", 3, false},
|
||||
{"elite", 1, true},
|
||||
{"boss", 1, true},
|
||||
} {
|
||||
chain, ambush := mischiefMonsters(tc.tier, zone, rng)
|
||||
if len(chain) != tc.wantLen {
|
||||
t.Errorf("%s: chain of %d, want %d", tc.tier, len(chain), tc.wantLen)
|
||||
}
|
||||
if ambush != tc.wantAmbush {
|
||||
t.Errorf("%s: ambush = %v, want %v", tc.tier, ambush, tc.wantAmbush)
|
||||
}
|
||||
for _, m := range chain {
|
||||
if m.Name == "" {
|
||||
t.Errorf("%s: chain holds an empty monster — the bestiary lookup missed", tc.tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Boss tier sends the zone's actual boss; elite sends something flagged elite.
|
||||
if chain, _ := mischiefMonsters("boss", zone, rng); chain[0].ID != zone.Boss.BestiaryID {
|
||||
t.Errorf("boss tier sent %q, want the zone boss %q", chain[0].ID, zone.Boss.BestiaryID)
|
||||
}
|
||||
elite, _ := mischiefMonsters("elite", zone, rng)
|
||||
found := false
|
||||
for _, e := range zone.Enemies {
|
||||
if e.BestiaryID == elite[0].ID && e.IsElite {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("elite tier sent %q, which isn't an elite in %s", elite[0].ID, zone.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── End-to-end: the delivery path against a real character ───────────────────
|
||||
|
||||
// seedMischiefTarget builds a real, playable adventurer on a live expedition —
|
||||
// the thing a contract actually lands on. Unit tests on the contract row prove
|
||||
// nothing about the fight; these drive runMischiefInterrupt and the close-outs.
|
||||
func seedMischiefTarget(t *testing.T, uid id.UserID, level, hp int) *Expedition {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, "target"+uid.Localpart()); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
if err := SaveDnDCharacter(&DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||||
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: hp, HPCurrent: hp, ArmorClass: 18,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(`
|
||||
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, coins_earned)
|
||||
VALUES (?, ?, 'goblin_warrens', ?, 'active', ?, 500)`,
|
||||
"exp-"+uid.Localpart(), string(uid), run.RunID, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("seed expedition: %v", err)
|
||||
}
|
||||
exp, err := getActiveExpedition(uid)
|
||||
if err != nil || exp == nil {
|
||||
t.Fatalf("getActiveExpedition: %v", err)
|
||||
}
|
||||
return exp
|
||||
}
|
||||
|
||||
// A grunt against a healthy level-5 fighter: the fight actually runs, it costs
|
||||
// HP, and they live. This is the "mostly theatre" tier doing what it was priced
|
||||
// to do.
|
||||
func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
uid := id.UserID("@grunted:example.org")
|
||||
exp := seedMischiefTarget(t, uid, 5, 60)
|
||||
|
||||
bracket := mischiefBracketZone(5)
|
||||
chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2)))
|
||||
|
||||
_, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush, 0)
|
||||
if !survived {
|
||||
t.Fatalf("a level-5 fighter at full HP died to a grunt (%s) — the tier is mispriced", monster)
|
||||
}
|
||||
hp, _ := dndHPSnapshot(uid)
|
||||
if hp <= 0 {
|
||||
t.Errorf("survivor is at %d HP", hp)
|
||||
}
|
||||
}
|
||||
|
||||
// The boss tier is the dangerous one, and the one where the no-perma-death rule
|
||||
// has to hold. A level-3 caster-grade sheet against a tier-1 boss goes down —
|
||||
// and must come back up at 1 HP with the expedition force-extracted, never dead.
|
||||
//
|
||||
// This is the property that separates mischief from paid murder: it is bought,
|
||||
// and it lands while the victim may well be asleep.
|
||||
func TestMischiefDelivery_DownedTargetIsMaimedNotKilled(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||
uid := id.UserID("@doomed:example.org")
|
||||
// 1 HP: whatever the dice do, this fight ends with them on the floor.
|
||||
exp := seedMischiefTarget(t, uid, 3, 1)
|
||||
|
||||
c := seedContract(t, "@buyer:example.org", uid, "boss", false)
|
||||
if !claimMischiefForDelivery(c.ID) {
|
||||
t.Fatal("claim should win")
|
||||
}
|
||||
if err := p.deliverMischief(c, exp); err != nil {
|
||||
t.Fatalf("deliverMischief: %v", err)
|
||||
}
|
||||
|
||||
// Alive, floored, and carried home.
|
||||
char, err := loadAdvCharacter(uid)
|
||||
if err != nil || char == nil {
|
||||
t.Fatal("target's character is gone")
|
||||
}
|
||||
if !char.Alive {
|
||||
t.Fatal("a PURCHASED monster killed a player outright — mischief must maim, never murder")
|
||||
}
|
||||
if hp, _ := dndHPSnapshot(uid); hp != 1 {
|
||||
t.Errorf("downed target is at %d HP, want the 1 HP floor", hp)
|
||||
}
|
||||
if e, _ := getActiveExpedition(uid); e != nil {
|
||||
t.Error("the expedition survived a downing — it must be force-extracted")
|
||||
}
|
||||
|
||||
// The buyer gets nothing back; the whole fee is a sink.
|
||||
got, err := mischiefContractByID(c.ID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatal("contract row vanished")
|
||||
}
|
||||
if got.Status != mischiefStatusDelivered || got.Outcome != mischiefOutcomeDowned {
|
||||
t.Errorf("contract resolved as %s/%s, want delivered/downed", got.Status, got.Outcome)
|
||||
}
|
||||
if pot := communityPotBalance(); pot < c.Paid {
|
||||
t.Errorf("community pot holds %d, want at least the %d fee — a landed hit refunds nobody", pot, c.Paid)
|
||||
}
|
||||
if bal := p.euro.GetBalance(c.BuyerID); bal > 0 {
|
||||
t.Errorf("buyer was credited %.0f on a successful hit; glory only", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Survival pays the target out of the buyer's money, and never more than the
|
||||
// buyer spent. Driven through the real close-out, not the arithmetic helper.
|
||||
func TestMischiefDelivery_SurvivalPaysTheTarget(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||
uid := id.UserID("@tough:example.org")
|
||||
exp := seedMischiefTarget(t, uid, 12, 400) // comfortably survives a tier-1 grunt
|
||||
|
||||
c := seedContract(t, "@buyer:example.org", uid, "grunt", false)
|
||||
if !claimMischiefForDelivery(c.ID) {
|
||||
t.Fatal("claim should win")
|
||||
}
|
||||
before := p.euro.GetBalance(uid)
|
||||
if err := p.deliverMischief(c, exp); err != nil {
|
||||
t.Fatalf("deliverMischief: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mischiefContractByID(c.ID)
|
||||
if got.Outcome != mischiefOutcomeSurvived {
|
||||
t.Fatalf("a level-12 fighter with 400 HP lost to a tier-1 grunt (outcome %s)", got.Outcome)
|
||||
}
|
||||
tier, _ := mischiefTierByKey("grunt")
|
||||
purse := mischiefPurse(tier.Fee, tier.PayoutPct)
|
||||
if gained := int(p.euro.GetBalance(uid) - before); gained != purse {
|
||||
t.Errorf("survivor was paid %d, want the %d purse", gained, purse)
|
||||
}
|
||||
if purse >= c.Paid {
|
||||
t.Errorf("purse %d >= outlay %d", purse, c.Paid)
|
||||
}
|
||||
// The remainder is a sink, not a rebate.
|
||||
if pot := communityPotBalance(); pot != c.Paid-purse {
|
||||
t.Errorf("pot holds %d, want the %d remainder", pot, c.Paid-purse)
|
||||
}
|
||||
// The expedition is untouched — they fought it off and walk on.
|
||||
if e, _ := getActiveExpedition(uid); e == nil {
|
||||
t.Error("surviving a contract ended the expedition")
|
||||
}
|
||||
}
|
||||
|
||||
// An expedition that ends before the window closes fizzles: the buyer is
|
||||
// refunded 90%, the town rakes the rest, and nobody is attacked.
|
||||
func TestMischiefDelivery_FizzlesWhenTargetWentHome(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||
uid := id.UserID("@gone:example.org")
|
||||
seedMischiefTarget(t, uid, 5, 60)
|
||||
|
||||
c := seedContract(t, "@buyer:example.org", uid, "elite", false)
|
||||
// They extract before the monster arrives.
|
||||
if _, _, err := forcedExtractExpedition("exp-"+uid.Localpart(), "went home"); err != nil {
|
||||
t.Fatalf("forcedExtractExpedition: %v", err)
|
||||
}
|
||||
|
||||
// Tomorrow at noon UTC: past the contract's window, and deliberately nowhere
|
||||
// near the 06:00 briefing or the 21:00 recap. fireMischiefDeliveries refuses to
|
||||
// run inside those quiet windows, so a wall-clock `now` here makes this test
|
||||
// fail for two hours of every real day.
|
||||
due := time.Now().UTC().AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(12 * time.Hour)
|
||||
p.fireMischiefDeliveries(due)
|
||||
|
||||
got, _ := mischiefContractByID(c.ID)
|
||||
if got.Status != mischiefStatusFizzled {
|
||||
t.Fatalf("contract status %s, want fizzled — the dungeon was empty", got.Status)
|
||||
}
|
||||
refund := int(float64(c.Paid) * mischiefFizzleRefund)
|
||||
if bal := int(p.euro.GetBalance(c.BuyerID)); bal != refund {
|
||||
t.Errorf("buyer refunded %d, want %d (90%%)", bal, refund)
|
||||
}
|
||||
if pot := communityPotBalance(); pot != c.Paid-refund {
|
||||
t.Errorf("pot raked %d, want %d", pot, c.Paid-refund)
|
||||
}
|
||||
if hp, max := dndHPSnapshot(uid); hp != max {
|
||||
t.Errorf("a fizzled contract still hurt the target (%d/%d HP)", hp, max)
|
||||
}
|
||||
}
|
||||
|
||||
// The limbo state. A leader can win a fight their friend went down in — and a
|
||||
// mischief delivery deliberately skips the close-outs that would normally mark a
|
||||
// 0-HP seat dead. So the seat must be floored on BOTH outcomes, or the member is
|
||||
// left alive at 0 HP: not dead enough for the hospital, too dead to act, and
|
||||
// read as broken by every gate that tests `HPCurrent <= 0`.
|
||||
func TestMischiefDelivery_SurvivalFloorsADroppedPartyMember(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||
leader := id.UserID("@leader:example.org")
|
||||
member := id.UserID("@member:example.org")
|
||||
|
||||
exp := seedMischiefTarget(t, leader, 12, 400) // wins comfortably
|
||||
seatLeaderFixture(t, exp.ID)
|
||||
if err := createAdvCharacter(member, "member"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveDnDCharacter(&DnDCharacter{
|
||||
UserID: member, Race: RaceHuman, Class: ClassFighter, Level: 3,
|
||||
STR: 14, DEX: 12, CON: 12, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 30, HPCurrent: 30, ArmorClass: 12,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := joinParty(exp.ID, member); err != nil {
|
||||
t.Fatalf("joinParty: %v", err)
|
||||
}
|
||||
// Put the member on the floor the way the fight would.
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(member)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := seedContract(t, "@buyer:example.org", leader, "grunt", false)
|
||||
if !claimMischiefForDelivery(c.ID) {
|
||||
t.Fatal("claim should win")
|
||||
}
|
||||
if err := p.deliverMischief(c, exp); err != nil {
|
||||
t.Fatalf("deliverMischief: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mischiefContractByID(c.ID)
|
||||
if got.Outcome != mischiefOutcomeSurvived {
|
||||
t.Fatalf("leader lost; this test needs a survival (outcome %s)", got.Outcome)
|
||||
}
|
||||
hp, _ := dndHPSnapshot(member)
|
||||
if hp <= 0 {
|
||||
t.Errorf("dropped party member left at %d HP on a survived contract — alive, but stuck at zero", hp)
|
||||
}
|
||||
if mc, _ := loadAdvCharacter(member); mc == nil || !mc.Alive {
|
||||
t.Error("a bought monster killed a party member — mischief maims, it never murders")
|
||||
}
|
||||
}
|
||||
|
||||
// ── M2: the window (bless / escalate) ────────────────────────────────────────
|
||||
|
||||
// mischiefWindowPlugin is a plugin wired for command-level tests: a euro ledger
|
||||
// and a sink, so `!mischief bless/escalate` can be driven the way a player drives
|
||||
// them (the CAS races these commands lose are the whole point of M2, and they only
|
||||
// exist on the command path).
|
||||
func mischiefWindowPlugin(t *testing.T) (*AdventurePlugin, *captureSink) {
|
||||
t.Helper()
|
||||
sink := &captureSink{}
|
||||
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||
p.Sink = sink
|
||||
return p, sink
|
||||
}
|
||||
|
||||
func mischiefCtx(sender id.UserID) MessageContext {
|
||||
return MessageContext{Sender: sender, RoomID: id.RoomID("!room:x"), EventID: id.EventID("$e")}
|
||||
}
|
||||
|
||||
// A ward is priced against the thing it defends against. The M1-close-out sweep
|
||||
// measured three wards buying ~+40pp of survival, which at a flat fee let the room
|
||||
// halve a €1,200 boss contract for €75 — the tier the whole economy rests on,
|
||||
// bought off for pocket change. Cheap at the impulse tiers, real money at the top.
|
||||
func TestMischiefBlessingFee_ScalesWithTheContract(t *testing.T) {
|
||||
want := map[string]int{"grunt": 25, "mob": 25, "elite": 35, "boss": 120}
|
||||
for _, tier := range mischiefTiers {
|
||||
if got := mischiefBlessingFee(tier.Fee); got != want[tier.Key] {
|
||||
t.Errorf("%s ward = %d, want %d", tier.Key, got, want[tier.Key])
|
||||
}
|
||||
// Where a ward is a rational purchase — the tiers the sweep says can
|
||||
// actually put someone on the floor — covering a target must cost less than
|
||||
// the contract does. Otherwise the room's counterplay is priced out and the
|
||||
// window is dead.
|
||||
//
|
||||
// Grunt and mob are deliberately exempt: the €25 floor is above-market
|
||||
// there (3 wards cost more than a €40 grunt), and that is fine, because
|
||||
// nothing in the sweep needs warding against theatre — those tiers sit at
|
||||
// 100% survival unwounded. The floor is there to keep a ward an impulse
|
||||
// buy, not to make it a sensible one against a goblin.
|
||||
if tier.Key != "elite" && tier.Key != "boss" {
|
||||
continue
|
||||
}
|
||||
if full := mischiefBlessingFee(tier.Fee) * mischiefBlessingCap; full >= tier.Fee {
|
||||
t.Errorf("%s: %d wards cost %d, which is more than the %d contract — "+
|
||||
"the room can't afford to save anyone", tier.Key, mischiefBlessingCap, full, tier.Fee)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cap lives in the UPDATE, not in the read that precedes it. A room that
|
||||
// piles four wards on in the same second must still land exactly three — and the
|
||||
// player who lost that race must get their money back rather than pay for
|
||||
// nothing.
|
||||
func TestMischiefBlessing_CapIsEnforcedByTheUpdate(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
c := seedContract(t, "@buyer:x", "@target:x", "elite", false)
|
||||
|
||||
for i := 1; i <= mischiefBlessingCap; i++ {
|
||||
if !blessMischiefContract(c.ID) {
|
||||
t.Fatalf("blessing %d/%d was rejected", i, mischiefBlessingCap)
|
||||
}
|
||||
}
|
||||
if blessMischiefContract(c.ID) {
|
||||
t.Errorf("a %dth blessing landed — the cap is not enforced", mischiefBlessingCap+1)
|
||||
}
|
||||
got, _ := mischiefContractByID(c.ID)
|
||||
if got.BlessingCount != mischiefBlessingCap {
|
||||
t.Errorf("blessing_count = %d, want %d", got.BlessingCount, mischiefBlessingCap)
|
||||
}
|
||||
// And nobody may ward a contract that has already been claimed for delivery:
|
||||
// the monster is in the room with them.
|
||||
c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false)
|
||||
if !claimMischiefForDelivery(c2.ID) {
|
||||
t.Fatal("claim should win")
|
||||
}
|
||||
if blessMischiefContract(c2.ID) {
|
||||
t.Error("warded a contract mid-delivery — the fight had already started")
|
||||
}
|
||||
}
|
||||
|
||||
// A blessing buys HP, never euros: the fee is a sink (community pot), and the
|
||||
// purse is untouched by it. Otherwise a target's friend could ward them as a way
|
||||
// of routing money into their pocket on the way out.
|
||||
func TestMischiefBlessing_FeeIsASinkNotAPurseTopUp(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p, _ := mischiefWindowPlugin(t)
|
||||
target := id.UserID("@warded:x")
|
||||
friend := id.UserID("@friend:x")
|
||||
p.euro.Credit(friend, 500, "test")
|
||||
|
||||
c := seedContract(t, "@buyer:x", target, "elite", false)
|
||||
feeBefore := c.Fee
|
||||
ward := mischiefBlessingFee(c.Fee) // 10% of the €350 elite fee
|
||||
|
||||
if err := p.mischiefBlessCmd(mischiefCtx(friend), []string{string(target)}); err != nil {
|
||||
t.Fatalf("bless: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mischiefContractByID(c.ID)
|
||||
if got.BlessingCount != 1 {
|
||||
t.Fatalf("blessing_count = %d, want 1", got.BlessingCount)
|
||||
}
|
||||
if got.Fee != feeBefore {
|
||||
t.Errorf("a blessing moved the payout basis to %d (was %d) — wards must not pay the target",
|
||||
got.Fee, feeBefore)
|
||||
}
|
||||
if bal := p.euro.GetBalance(friend); bal != float64(500-ward) {
|
||||
t.Errorf("blesser balance %.0f, want %d", bal, 500-ward)
|
||||
}
|
||||
if pot := communityPotBalance(); pot != ward {
|
||||
t.Errorf("pot holds %d, want the %d ward fee", pot, ward)
|
||||
}
|
||||
// Broke, so the second ward can't be bought — and must cost nothing.
|
||||
pauper := id.UserID("@pauper:x")
|
||||
if err := p.mischiefBlessCmd(mischiefCtx(pauper), []string{string(target)}); err != nil {
|
||||
t.Fatalf("bless: %v", err)
|
||||
}
|
||||
if bal := p.euro.GetBalance(pauper); bal != 0 {
|
||||
t.Errorf("a failed blessing moved the pauper's balance to %.0f", bal)
|
||||
}
|
||||
if got, _ := mischiefContractByID(c.ID); got.BlessingCount != 1 {
|
||||
t.Errorf("a blessing nobody could pay for still landed (count %d)", got.BlessingCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Escalation moves the tier, the payout basis and the outlay together — which is
|
||||
// what keeps the anti-collusion invariant (purse < what was spent) true after the
|
||||
// room has piled on. It also happens exactly once.
|
||||
func TestMischiefEscalation_RaisesBasisAndOutlayTogether(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p, _ := mischiefWindowPlugin(t)
|
||||
target := id.UserID("@hunted:x")
|
||||
piler := id.UserID("@piler:x")
|
||||
rival := id.UserID("@rival:x")
|
||||
p.euro.Credit(piler, 5000, "test")
|
||||
p.euro.Credit(rival, 5000, "test")
|
||||
|
||||
c := seedContract(t, "@buyer:x", target, "elite", true) // signed: paid > fee
|
||||
elite, _ := mischiefTierByKey("elite")
|
||||
boss, _ := mischiefTierByKey("boss")
|
||||
delta := boss.Fee - elite.Fee
|
||||
|
||||
if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
|
||||
t.Fatalf("escalate: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mischiefContractByID(c.ID)
|
||||
if got.Tier != "boss" || got.Fee != boss.Fee {
|
||||
t.Fatalf("contract is %s/fee %d, want boss/%d", got.Tier, got.Fee, boss.Fee)
|
||||
}
|
||||
if got.Paid != c.Paid+delta {
|
||||
t.Errorf("paid = %d, want %d (%d + the %d delta)", got.Paid, c.Paid+delta, c.Paid, delta)
|
||||
}
|
||||
if got.EscalatedBy != piler {
|
||||
t.Errorf("escalated_by = %q, want %s — the unseal has nobody to name", got.EscalatedBy, piler)
|
||||
}
|
||||
if bal := p.euro.GetBalance(piler); bal != float64(5000-delta) {
|
||||
t.Errorf("escalator paid %.0f, want the %d delta", 5000-bal, delta)
|
||||
}
|
||||
// The invariant, after the escalation: the purse is still strictly less than
|
||||
// the money that went in.
|
||||
if purse := mischiefPurse(got.Fee, boss.PayoutPct); purse >= got.Paid {
|
||||
t.Errorf("escalated purse %d >= outlay %d — collusion now beats !baltransfer", purse, got.Paid)
|
||||
}
|
||||
|
||||
// One step, once — whoever else was reaching for it is refunded.
|
||||
if err := p.mischiefEscalateCmd(mischiefCtx(rival), []string{string(target)}); err != nil {
|
||||
t.Fatalf("second escalate: %v", err)
|
||||
}
|
||||
if bal := p.euro.GetBalance(rival); bal != 5000 {
|
||||
t.Errorf("the losing escalator is out %.0f — a rejected escalation must cost nothing", 5000-bal)
|
||||
}
|
||||
after, _ := mischiefContractByID(c.ID)
|
||||
if after.EscalationCount != 1 || after.Paid != got.Paid {
|
||||
t.Errorf("contract escalated twice (count %d, paid %d)", after.EscalationCount, after.Paid)
|
||||
}
|
||||
}
|
||||
|
||||
// A fizzle refunds `paid`, and after an escalation `paid` is TWO people's money.
|
||||
// Refunding all of it to the buyer is a money pump: buy an elite for €350, have
|
||||
// somebody bump it to a boss (+€850), let the target walk out of the dungeon, and
|
||||
// the 90% refund hands the buyer €1080 — a €730 profit funded entirely by the
|
||||
// escalator, who gets nothing and is never told.
|
||||
func TestMischiefFizzle_RefundsTheEscalatorTheirOwnMoney(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p, _ := mischiefWindowPlugin(t)
|
||||
target := id.UserID("@hunted:x")
|
||||
buyer := id.UserID("@buyer:x")
|
||||
piler := id.UserID("@piler:x")
|
||||
p.euro.Credit(piler, 5000, "test")
|
||||
exp := seedMischiefTarget(t, target, 5, 60)
|
||||
|
||||
c := seedContract(t, buyer, target, "elite", false)
|
||||
elite, _ := mischiefTierByKey("elite")
|
||||
boss, _ := mischiefTierByKey("boss")
|
||||
delta := boss.Fee - elite.Fee
|
||||
|
||||
if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
|
||||
t.Fatalf("escalate: %v", err)
|
||||
}
|
||||
// They extract before the boss arrives. Nobody's monster ever lands.
|
||||
if _, _, err := forcedExtractExpedition(exp.ID, "went home"); err != nil {
|
||||
t.Fatalf("forcedExtractExpedition: %v", err)
|
||||
}
|
||||
due := time.Now().UTC().AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(12 * time.Hour)
|
||||
p.fireMischiefDeliveries(due)
|
||||
|
||||
if got, _ := mischiefContractByID(c.ID); got.Status != mischiefStatusFizzled {
|
||||
t.Fatalf("contract status %s, want fizzled", got.Status)
|
||||
}
|
||||
|
||||
// Each stakeholder is refunded 90% of THEIR OWN stake — not 90% of the pooled
|
||||
// `paid`, which is what the buyer would otherwise pocket.
|
||||
wantBuyer := int(float64(c.Paid) * mischiefFizzleRefund)
|
||||
wantPiler := int(float64(delta) * mischiefFizzleRefund)
|
||||
if bal := int(p.euro.GetBalance(buyer)); bal != wantBuyer {
|
||||
t.Errorf("buyer refunded %d, want %d — they were handed the escalator's stake", bal, wantBuyer)
|
||||
}
|
||||
if spent := 5000 - int(p.euro.GetBalance(piler)); spent != delta-wantPiler {
|
||||
t.Errorf("escalator is out %d, want %d (their 10%% rake) — their refund went elsewhere",
|
||||
spent, delta-wantPiler)
|
||||
}
|
||||
if pot, want := communityPotBalance(), (c.Paid-wantBuyer)+(delta-wantPiler); pot != want {
|
||||
t.Errorf("pot raked %d, want %d", pot, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Boss tier is the "end their expedition" button, and the weekly cap on it is the
|
||||
// target's protection. An escalation into boss is still a boss landing on them, so
|
||||
// it answers to the same cap — otherwise the cap is bought around for the price of
|
||||
// an elite plus the delta.
|
||||
func TestMischiefEscalation_IntoBossRespectsTheWeeklyCap(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p, _ := mischiefWindowPlugin(t)
|
||||
target := id.UserID("@bossed:x")
|
||||
piler := id.UserID("@piler:x")
|
||||
p.euro.Credit(piler, 5000, "test")
|
||||
|
||||
// They already took a boss this week (delivered, so it counts).
|
||||
spent := seedContract(t, "@buyer:x", target, "boss", false)
|
||||
resolveMischiefContract(spent.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||
|
||||
c := seedContract(t, "@buyer2:x", target, "elite", false)
|
||||
if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
|
||||
t.Fatalf("escalate: %v", err)
|
||||
}
|
||||
got, _ := mischiefContractByID(c.ID)
|
||||
if got.Tier != "elite" {
|
||||
t.Errorf("escalated to %s — the weekly boss cap was bought around", got.Tier)
|
||||
}
|
||||
if bal := p.euro.GetBalance(piler); bal != 5000 {
|
||||
t.Errorf("a refused escalation charged %.0f", 5000-bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Boss is the top of the ladder, and a target may not raise the price on their
|
||||
// own head.
|
||||
func TestMischiefEscalation_RefusesBossAndSelfService(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p, _ := mischiefWindowPlugin(t)
|
||||
target := id.UserID("@top:x")
|
||||
piler := id.UserID("@piler:x")
|
||||
p.euro.Credit(piler, 5000, "test")
|
||||
p.euro.Credit(target, 5000, "test")
|
||||
|
||||
c := seedContract(t, "@buyer:x", target, "boss", false)
|
||||
if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
|
||||
t.Fatalf("escalate: %v", err)
|
||||
}
|
||||
if got, _ := mischiefContractByID(c.ID); got.EscalationCount != 0 {
|
||||
t.Error("something got escalated past boss tier")
|
||||
}
|
||||
if bal := p.euro.GetBalance(piler); bal != 5000 {
|
||||
t.Errorf("charged %.0f for an impossible escalation", 5000-bal)
|
||||
}
|
||||
|
||||
newMischiefTestDB(t)
|
||||
c2 := seedContract(t, "@buyer:x", target, "grunt", false)
|
||||
if err := p.mischiefEscalateCmd(mischiefCtx(target), []string{string(target)}); err != nil {
|
||||
t.Fatalf("escalate: %v", err)
|
||||
}
|
||||
if got, _ := mischiefContractByID(c2.ID); got.EscalationCount != 0 {
|
||||
t.Error("the target escalated their own contract")
|
||||
}
|
||||
}
|
||||
|
||||
// The window keeps writing to an open contract right up to the claim, so the
|
||||
// delivery must build the fight from the row as it stands AFTER the claim — not
|
||||
// from the copy the due-sweep handed it. A tier bought at the last second (an
|
||||
// escalation) is the visible half of this: read stale, the buyer pays for a boss
|
||||
// and the target fights a grunt.
|
||||
func TestMischiefDelivery_ReadsTheContractAfterTheClaim(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p, _ := mischiefWindowPlugin(t)
|
||||
uid := id.UserID("@lastsecond:x")
|
||||
seedMischiefTarget(t, uid, 12, 400)
|
||||
|
||||
c := seedContract(t, "@buyer:x", uid, "grunt", false)
|
||||
// Somebody escalates in the gap between the due-sweep and the claim. `c` is now
|
||||
// the stale copy the ticker is still holding.
|
||||
elite, _ := mischiefTierByKey("elite")
|
||||
if !escalateMischiefContract(c.ID, "grunt", elite, elite.Fee-c.Fee, "@piler:x") {
|
||||
t.Fatal("escalation should have taken")
|
||||
}
|
||||
|
||||
before := p.euro.GetBalance(uid)
|
||||
p.fireOneMischiefDelivery(c)
|
||||
|
||||
got, _ := mischiefContractByID(c.ID)
|
||||
if got.Outcome != mischiefOutcomeSurvived {
|
||||
t.Fatalf("target lost; this test needs a survival to read the purse (outcome %s)", got.Outcome)
|
||||
}
|
||||
want := mischiefPurse(elite.Fee, elite.PayoutPct)
|
||||
if gained := int(p.euro.GetBalance(uid) - before); gained != want {
|
||||
t.Errorf("survivor was paid %d, want the escalated %d purse — "+
|
||||
"the delivery ran off the pre-claim copy of the contract", gained, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The ward is a cushion for THIS fight and nothing else: it goes on the sheet as
|
||||
// temp HP for the delivery and comes off after — without eating the well-rested
|
||||
// cushion the target may already have been carrying out of the door.
|
||||
func TestMischiefBlessing_CushionsTheFightThenClearsItself(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p, _ := mischiefWindowPlugin(t)
|
||||
uid := id.UserID("@blessed:x")
|
||||
exp := seedMischiefTarget(t, uid, 12, 400)
|
||||
|
||||
// They long-rested at home before setting out.
|
||||
dc, _ := LoadDnDCharacter(uid)
|
||||
dc.TempHP = 32
|
||||
if err := SaveDnDCharacter(dc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := seedContract(t, "@buyer:x", uid, "grunt", false)
|
||||
for i := 0; i < mischiefBlessingCap; i++ {
|
||||
if !blessMischiefContract(c.ID) {
|
||||
t.Fatal("seed blessing rejected")
|
||||
}
|
||||
}
|
||||
c.BlessingCount = mischiefBlessingCap
|
||||
|
||||
want := mischiefBlessingTempHP(400, mischiefBlessingCap) // 3 × 10% of 400
|
||||
if want != 120 {
|
||||
t.Fatalf("ward is %d HP, want 120 — the fight is being cushioned by something else", want)
|
||||
}
|
||||
if !claimMischiefForDelivery(c.ID) {
|
||||
t.Fatal("claim should win")
|
||||
}
|
||||
if err := p.deliverMischief(c, exp); err != nil {
|
||||
t.Fatalf("deliverMischief: %v", err)
|
||||
}
|
||||
|
||||
after, _ := LoadDnDCharacter(uid)
|
||||
if after.TempHP != 32 {
|
||||
t.Errorf("temp HP is %d after the delivery, want the 32 they rested for — "+
|
||||
"the ward must not linger, and must not eat their own cushion", after.TempHP)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D2 NPC arcs — pure-helper coverage. None of these touch combat, so the
|
||||
// characterization golden is unaffected; these just pin the arc thresholds,
|
||||
// gift tiers, and the keepsake shape.
|
||||
|
||||
func TestMistyArcBeat_FiresOnlyAtThresholds(t *testing.T) {
|
||||
want := map[int]bool{5: true, 15: true, 30: true}
|
||||
for count := 0; count <= 40; count++ {
|
||||
got := mistyArcBeat(count)
|
||||
if want[count] {
|
||||
if got == "" {
|
||||
t.Errorf("encounter %d: expected an arc beat, got none", count)
|
||||
}
|
||||
} else if got != "" {
|
||||
t.Errorf("encounter %d: expected no arc beat, got %q", count, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyArcBeats_DoNotLeakBuffMechanics(t *testing.T) {
|
||||
// The arena effects are a hidden discovery mechanic; arc copy must never
|
||||
// tie a donation to a combat outcome.
|
||||
banned := []string{"arena", "damage", "buff", "combat", "attack", "heal", "bonus"}
|
||||
for count, beat := range mistyArcBeats {
|
||||
lower := strings.ToLower(beat)
|
||||
for _, b := range banned {
|
||||
if strings.Contains(lower, b) {
|
||||
t.Errorf("beat %d leaks mechanic word %q: %s", count, b, beat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobbieGiftTier_MatchesArenaBands(t *testing.T) {
|
||||
cases := []struct {
|
||||
level int
|
||||
tier int
|
||||
}{
|
||||
{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3},
|
||||
{13, 4}, {17, 4}, {18, 5}, {20, 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := robbieGiftTier(c.level); got != c.tier {
|
||||
t.Errorf("robbieGiftTier(%d) = %d, want %d", c.level, got, c.tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobbieGiftCadence(t *testing.T) {
|
||||
// The gift lands on every 10th visit and no other.
|
||||
for visit := 1; visit <= 35; visit++ {
|
||||
gives := visit%robbieGiftEveryNVisits == 0
|
||||
if gives != (visit == 10 || visit == 20 || visit == 30) {
|
||||
t.Errorf("visit %d: unexpected gift cadence", visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomPetTreat_IsRobbieSafeKeepsake(t *testing.T) {
|
||||
treat := thomPetTreatItem()
|
||||
if treat.Name == "" {
|
||||
t.Fatal("treat has no name")
|
||||
}
|
||||
// Robbie's sweep skips "card" items, so the keepsake survives him.
|
||||
if treat.Type != "card" {
|
||||
t.Errorf("treat type = %q, want card so Robbie's sweep skips it", treat.Type)
|
||||
}
|
||||
if treat.Value != 0 {
|
||||
t.Errorf("treat value = %d, want 0 (inert keepsake)", treat.Value)
|
||||
}
|
||||
// Confirm the sweep filter actually excludes it.
|
||||
if got := robbieQualifyingItems([]AdvItem{treat}, nil); len(got) != 0 {
|
||||
t.Errorf("Robbie would take the treat: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomFinalLetterPet_SubstitutesPetName(t *testing.T) {
|
||||
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", "Biscuit")
|
||||
if strings.Contains(letter, "{pet}") {
|
||||
t.Error("unsubstituted {pet} placeholder remains")
|
||||
}
|
||||
if !strings.Contains(letter, "Biscuit") {
|
||||
t.Error("pet name not substituted into the letter")
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
// ── NPC Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
mistyCost = 100
|
||||
arinaCost = 5000
|
||||
npcCooldownDays = 7
|
||||
mistyCost = 100
|
||||
arinaCost = 5000
|
||||
npcCooldownDays = 7
|
||||
npcEncounterChance = 0.075 // 7.5%
|
||||
npcBuffDuration = 7 * 24 * time.Hour
|
||||
npcBuffDuration = 7 * 24 * time.Hour
|
||||
|
||||
// Arena effect chances per round
|
||||
mistyEffectChance = 0.20 // 20%
|
||||
@@ -131,15 +131,6 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't consume the encounter — or its one-shot arc beat — if the player is
|
||||
// already mid-interaction (shop, treasure, another NPC). Bail before
|
||||
// touching MistyEncounterCount so a contended slot defers the whole
|
||||
// encounter to a later fire instead of durably advancing the counter past
|
||||
// a 5/15/30 threshold and losing that beat forever.
|
||||
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
var opening, prompt string
|
||||
@@ -150,12 +141,6 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
// Pool union: legacy mistyOpenings + D&D MistyGreeting flavor.
|
||||
mistyPool := dndMistyGreetingPool()
|
||||
opening = mistyPool[rand.IntN(len(mistyPool))]
|
||||
// D2 arc: a deepening beat lands the one time the counter hits a
|
||||
// threshold (5/15/30). MistyEncounterCount only ever increments by one
|
||||
// above, so each beat is delivered exactly once.
|
||||
if beat := mistyArcBeat(char.MistyEncounterCount); beat != "" {
|
||||
opening = beat + "\n\n" + opening
|
||||
}
|
||||
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
|
||||
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
|
||||
case "arina":
|
||||
@@ -174,6 +159,11 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
slog.Error("player_meta: npc last_seen dual-write failed", "user", userID, "npc", npc, "err", err)
|
||||
}
|
||||
|
||||
// Don't overwrite an existing pending interaction (shop, treasure, etc.)
|
||||
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending interaction — NPC encounters stay valid until end of UTC day
|
||||
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
@@ -347,10 +337,10 @@ func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharac
|
||||
// Returns: extra text to append to round log, enemy HP modifier, player damage taken.
|
||||
type npcArenaResult struct {
|
||||
Text string
|
||||
EnemyDmg int // damage dealt to enemy
|
||||
PlayerDmg int // damage dealt to player
|
||||
SniperKill bool // enemy instant kill
|
||||
CondRepair int // equipment condition repair amount
|
||||
EnemyDmg int // damage dealt to enemy
|
||||
PlayerDmg int // damage dealt to player
|
||||
SniperKill bool // enemy instant kill
|
||||
CondRepair int // equipment condition repair amount
|
||||
}
|
||||
|
||||
func npcCheckArenaEffects(char *AdventureCharacter, monsterName string) *npcArenaResult {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/B3 — the Omen: one rotating world modifier per ISO week
|
||||
// (gogobee_engagement_plan.md §B3).
|
||||
//
|
||||
// The active omen is a pure function of the ISO (year, week): omenTable indexed
|
||||
// by (year*53 + week) % len, so it advances by exactly one entry each week and
|
||||
// needs no schema, no ticker state, and no persistence. Every seam reads
|
||||
// activeOmen() the same way isHolidayToday() is read, and a zero-valued effect
|
||||
// field means "this omen doesn't touch that seam."
|
||||
//
|
||||
// Launch-set rule (§B3): every omen is a buff-with-texture on a NON-combat
|
||||
// lever — harvest, supplies, expedition mood/threat, arena payout, ingredient
|
||||
// drops. Nothing touches SimulateCombat or the turn engine: the omen is keyed
|
||||
// on the real clock, so a combat-affecting omen would make the combat golden
|
||||
// and the balance corpus week-dependent. The plan's "elites +2 ATK" mutator is
|
||||
// deliberately omitted for exactly that reason.
|
||||
|
||||
type omen struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing name, e.g. "Bountiful Harvest"
|
||||
TwinBee string // first-person announce blurb (TwinBee voice)
|
||||
|
||||
// Effect fields — zero == no effect at that seam.
|
||||
HarvestYieldBonus int // + units per harvest grant
|
||||
SupplyFreebiePacks int // + complimentary standard packs at outfitting
|
||||
StartMoodBonus int // + starting DM mood on a new expedition
|
||||
ArenaPayoutMult float64 // >1 scales arena net earnings
|
||||
ConsumableChanceMult float64 // >1 scales the per-win ingredient drop chance
|
||||
ThreatDriftReduce int // subtract from the daily threat *rise* (floored at hold-steady)
|
||||
}
|
||||
|
||||
// simOmenDisabled neutralizes the weekly Omen for the balance sim, so a corpus
|
||||
// sweep's results never depend on which wall-clock week it was run in. Set true
|
||||
// by NewSimRunner (mirrors simAutoArmEnabled). Every seam reads activeOmen(),
|
||||
// which returns the no-effect omen while this is set.
|
||||
var simOmenDisabled bool
|
||||
|
||||
// omenTable is the weekly rotation. Keep it a set of distinct non-combat levers;
|
||||
// order is the rotation order. Adding an entry reshuffles the schedule but never
|
||||
// breaks determinism (still a pure function of the week).
|
||||
var omenTable = []omen{
|
||||
{
|
||||
Key: "bountiful_harvest", Name: "Bountiful Harvest",
|
||||
TwinBee: "The land's feeling generous this week — every gather I make comes up with a little extra in hand.",
|
||||
HarvestYieldBonus: 1,
|
||||
},
|
||||
{
|
||||
Key: "quartermasters_blessing", Name: "Quartermaster's Blessing",
|
||||
TwinBee: "Somebody left the storerooms unlocked. Outfitting an expedition this week? There's a free pack in it, and I'm setting out in good spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
{
|
||||
Key: "golden_purse", Name: "Golden Purse",
|
||||
TwinBee: "The arena crowd's flush this week — purses are paying out fat. Twenty percent over the odds, if you can win it.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
{
|
||||
Key: "overflowing_satchels", Name: "Overflowing Satchels",
|
||||
TwinBee: "Reagents are turning up everywhere I look — twice as often as usual. Good week to stock the crafting shelf.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
{
|
||||
Key: "still_waters", Name: "Still Waters",
|
||||
TwinBee: "It's quiet out there. Whatever's hunting us is slow to rouse this week — the daily dread holds steady instead of creeping up.",
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
}
|
||||
|
||||
// omenForWeek returns the omen for an ISO (year, week). Pure and total.
|
||||
func omenForWeek(year, week int) omen {
|
||||
idx := ((year*53)+week)%len(omenTable) + len(omenTable)
|
||||
return omenTable[idx%len(omenTable)]
|
||||
}
|
||||
|
||||
// activeOmen returns this week's omen (UTC ISO week), or a no-effect omen when
|
||||
// the balance sim has disabled it.
|
||||
func activeOmen() omen {
|
||||
if simOmenDisabled {
|
||||
return omen{Key: "none", Name: "None"}
|
||||
}
|
||||
// N7/E4 — a live season's themed omen overrides the weekly rotation. Kept
|
||||
// behind the simOmenDisabled guard above so the balance sim never sees it.
|
||||
if s, ok := activeSeason(); ok {
|
||||
return s.Omen
|
||||
}
|
||||
y, w := time.Now().UTC().ISOWeek()
|
||||
return omenForWeek(y, w)
|
||||
}
|
||||
|
||||
// omenMorningLine is the compact one-liner surfaced in the morning DM (the §B3
|
||||
// announce seam). Empty is never returned — an omen is always active — but the
|
||||
// caller may still choose when to show it.
|
||||
func omenMorningLine(o omen) string {
|
||||
return "🔮 **The Omen — " + o.Name + ".** _" + o.TwinBee + "_"
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestOmenForWeek_Deterministic — the same ISO week always yields the same omen.
|
||||
func TestOmenForWeek_Deterministic(t *testing.T) {
|
||||
a := omenForWeek(2026, 28)
|
||||
b := omenForWeek(2026, 28)
|
||||
if a.Key != b.Key {
|
||||
t.Fatalf("omenForWeek not deterministic: %q vs %q", a.Key, b.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_AdvancesEachWeek — consecutive weeks step by exactly one table
|
||||
// entry, so the schedule rotates rather than sticking or skipping.
|
||||
func TestOmenForWeek_AdvancesEachWeek(t *testing.T) {
|
||||
n := len(omenTable)
|
||||
for w := 1; w <= n; w++ {
|
||||
cur := omenForWeek(2026, w)
|
||||
next := omenForWeek(2026, w+1)
|
||||
wantNext := omenTable[((2026*53)+w+1)%n]
|
||||
if next.Key != wantNext.Key {
|
||||
t.Errorf("week %d→%d: got %q, want %q", w, w+1, next.Key, wantNext.Key)
|
||||
}
|
||||
if cur.Key == next.Key {
|
||||
t.Errorf("week %d and %d produced the same omen %q (should advance)", w, w+1, cur.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_TotalOverYearBoundary — never panics across week/year edges,
|
||||
// including ISO week 53.
|
||||
func TestOmenForWeek_TotalOverYearBoundary(t *testing.T) {
|
||||
for y := 2020; y <= 2030; y++ {
|
||||
for w := 1; w <= 53; w++ {
|
||||
o := omenForWeek(y, w)
|
||||
if o.Key == "" {
|
||||
t.Fatalf("omenForWeek(%d,%d) returned zero omen", y, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenTable_NonCombatOnly — every omen carries at least one effect and the
|
||||
// struct has no combat lever, so the launch set can never move the golden or the
|
||||
// balance corpus. §B3.
|
||||
func TestOmenTable_NonCombatOnly(t *testing.T) {
|
||||
for _, o := range omenTable {
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("omen %q has no active effect", o.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("omen %q missing player-facing copy", o.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveOmen_SimDisabled — the balance sim neutralizes the omen so corpus
|
||||
// results are wall-clock-independent. §B3.
|
||||
func TestActiveOmen_SimDisabled(t *testing.T) {
|
||||
defer func() { simOmenDisabled = false }()
|
||||
simOmenDisabled = true
|
||||
o := activeOmen()
|
||||
if o.Key != "none" {
|
||||
t.Errorf("sim-disabled omen = %q, want none", o.Key)
|
||||
}
|
||||
if o.HarvestYieldBonus != 0 || o.SupplyFreebiePacks != 0 || o.StartMoodBonus != 0 ||
|
||||
o.ArenaPayoutMult != 0 || o.ConsumableChanceMult != 0 || o.ThreatDriftReduce != 0 {
|
||||
t.Errorf("sim-disabled omen must have no effect, got %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenKeysUnique — no duplicate keys (a dup would make the rotation land on
|
||||
// the same omen two of every len(omenTable) weeks).
|
||||
func TestOmenKeysUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range omenTable {
|
||||
if seen[o.Key] {
|
||||
t.Errorf("duplicate omen key %q", o.Key)
|
||||
}
|
||||
seen[o.Key] = true
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestDerivePlayerStats_OnePetIsByteIdentical pins that a single pet still
|
||||
// produces exactly the pre-two-pet mod values (the averaging code must reduce
|
||||
// to identity over one element, or the combat golden moves).
|
||||
func TestDerivePlayerStats_OnePetIsByteIdentical(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType = "dog"
|
||||
char.PetName = "Rex"
|
||||
char.PetArrived = true
|
||||
char.PetLevel = 6
|
||||
char.PetArmorTier = 2
|
||||
|
||||
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.PetAttackProc != petAttackChance(6) {
|
||||
t.Errorf("PetAttackProc = %v, want %v (identity over one pet)", stats.PetAttackProc, petAttackChance(6))
|
||||
}
|
||||
if stats.PetDeflectProc != petDeflectChance(6, 2) {
|
||||
t.Errorf("PetDeflectProc = %v, want %v", stats.PetDeflectProc, petDeflectChance(6, 2))
|
||||
}
|
||||
if want := 0.01 + 6*0.005; stats.PetWhiffProc != want {
|
||||
t.Errorf("PetWhiffProc = %v, want %v", stats.PetWhiffProc, want)
|
||||
}
|
||||
if stats.PetAttackDmg != 3+6 {
|
||||
t.Errorf("PetAttackDmg = %d, want %d", stats.PetAttackDmg, 3+6)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivePlayerStats_TwoEqualPetsMatchOne: two identical pets average to the
|
||||
// same procs as one — a pair is not a stat spike.
|
||||
func TestDerivePlayerStats_TwoEqualPetsMatchOne(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType, char.PetName, char.PetArrived, char.PetLevel, char.PetArmorTier = "dog", "Rex", true, 8, 3
|
||||
char.Pet2Type, char.Pet2Name, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", "Luna", true, 8, 3
|
||||
|
||||
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.PetAttackProc != petAttackChance(8) {
|
||||
t.Errorf("two L8 pets PetAttackProc = %v, want one-pet %v", stats.PetAttackProc, petAttackChance(8))
|
||||
}
|
||||
if stats.PetAttackDmg != 3+8 {
|
||||
t.Errorf("two L8 pets PetAttackDmg = %d, want %d", stats.PetAttackDmg, 3+8)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivePlayerStats_TwoUnequalPetsAverage: mixed levels average, never
|
||||
// exceeding the stronger pet's solo contribution.
|
||||
func TestDerivePlayerStats_TwoUnequalPetsAverage(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType, char.PetArrived, char.PetLevel, char.PetArmorTier = "dog", true, 10, 0
|
||||
char.Pet2Type, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", true, 2, 0
|
||||
|
||||
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
|
||||
wantAtk := (petAttackChance(10) + petAttackChance(2)) / 2
|
||||
if stats.PetAttackProc != wantAtk {
|
||||
t.Errorf("PetAttackProc = %v, want avg %v", stats.PetAttackProc, wantAtk)
|
||||
}
|
||||
// Averaged proc must sit strictly between the two pets' solo values.
|
||||
if !(stats.PetAttackProc < petAttackChance(10) && stats.PetAttackProc > petAttackChance(2)) {
|
||||
t.Errorf("averaged proc %v not between %v and %v", stats.PetAttackProc, petAttackChance(2), petAttackChance(10))
|
||||
}
|
||||
if want := 3 + (10+2+1)/2; stats.PetAttackDmg != want { // rounded avg level = 6
|
||||
t.Errorf("PetAttackDmg = %d, want %d", stats.PetAttackDmg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivePlayerStats_Pet2OnlyStillContributes: if the first pet is chased
|
||||
// away but the second remains, combat still sees the second pet.
|
||||
func TestDerivePlayerStats_Pet2OnlyStillContributes(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType, char.PetArrived, char.PetChasedAway, char.PetLevel = "dog", true, true, 5
|
||||
char.Pet2Type, char.Pet2Arrived, char.Pet2Level = "cat", true, 4
|
||||
|
||||
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if !char.HasPet() && stats.PetAttackProc != petAttackChance(4) {
|
||||
t.Errorf("pet2-only PetAttackProc = %v, want %v", stats.PetAttackProc, petAttackChance(4))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivePlayerStats_NoPetsZero: the golden's own case — no pets, no procs.
|
||||
func TestDerivePlayerStats_NoPetsZero(t *testing.T) {
|
||||
_, stats := DerivePlayerStats(testChar(10), testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
if stats.PetAttackProc != 0 || stats.PetDeflectProc != 0 || stats.PetWhiffProc != 0 || stats.PetAttackDmg != 0 {
|
||||
t.Errorf("petless character got pet mods: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive2_Gating(t *testing.T) {
|
||||
pet1 := PetState{Type: "dog", Arrived: true}
|
||||
tier4 := HouseState{Tier: 4}
|
||||
tier3 := HouseState{Tier: 3}
|
||||
|
||||
if petShouldArrive2(pet1, PetState{}, tier3) {
|
||||
t.Error("second pet must not arrive below Tier 4")
|
||||
}
|
||||
if petShouldArrive2(PetState{}, PetState{}, tier4) {
|
||||
t.Error("second pet needs an established first pet")
|
||||
}
|
||||
if petShouldArrive2(pet1, PetState{Arrived: true}, tier4) {
|
||||
t.Error("already-arrived second pet must not re-roll")
|
||||
}
|
||||
if petShouldArrive2(pet1, PetState{ChasedAway: true}, tier4) {
|
||||
t.Error("chased-away second pet does not re-arrive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPet2StoreRoundTrip exercises the pet2_* columns and the AdvChar mirror.
|
||||
func TestPet2StoreRoundTrip(t *testing.T) {
|
||||
townTestDB(t)
|
||||
user := id.UserID("@pet2:test.invalid")
|
||||
|
||||
char := &AdventureCharacter{UserID: user}
|
||||
char.PetType, char.PetName, char.PetArrived, char.PetLevel = "dog", "Rex", true, 3
|
||||
char.Pet2Type, char.Pet2Name, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", "Luna", true, 5, 2
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := loadAdvCharacter(user)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if !got.HasPet2() || got.Pet2Name != "Luna" || got.Pet2Level != 5 || got.Pet2ArmorTier != 2 || got.Pet2Type != "cat" {
|
||||
t.Fatalf("pet2 did not round-trip: %+v", got)
|
||||
}
|
||||
if !got.HasPet() || got.PetName != "Rex" {
|
||||
t.Fatalf("pet1 clobbered by pet2 write: %+v", got)
|
||||
}
|
||||
|
||||
// Direct slot loader agrees.
|
||||
p2, _ := loadPet2State(user)
|
||||
if p2.Name != "Luna" || p2.Level != 5 {
|
||||
t.Fatalf("loadPet2State mismatch: %+v", p2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPetShowcase_IncludesBothSlots verifies the town showcase surfaces second
|
||||
// pets and hides a chased-away one.
|
||||
func TestPetShowcase_IncludesBothSlots(t *testing.T) {
|
||||
townTestDB(t)
|
||||
|
||||
a := &AdventureCharacter{UserID: id.UserID("@a:test.invalid")}
|
||||
a.PetType, a.PetName, a.PetArrived, a.PetLevel = "dog", "Rex", true, 4
|
||||
a.Pet2Type, a.Pet2Name, a.Pet2Arrived, a.Pet2Level = "cat", "Luna", true, 9
|
||||
if err := saveAdvCharacter(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &AdventureCharacter{UserID: id.UserID("@b:test.invalid")}
|
||||
b.Pet2Type, b.Pet2Name, b.Pet2Arrived, b.Pet2Level, b.Pet2ChasedAway = "dog", "Ghost", true, 7, true
|
||||
if err := saveAdvCharacter(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pets, err := loadPetShowcase(townListCap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pets) != 2 {
|
||||
t.Fatalf("want 2 active pets (Rex + Luna), got %d: %+v", len(pets), pets)
|
||||
}
|
||||
// Sorted by level desc → Luna (9) before Rex (4).
|
||||
if !strings.Contains(pets[0].Name, "Luna") || !strings.Contains(pets[1].Name, "Rex") {
|
||||
t.Fatalf("showcase not level-ordered across slots: %+v", pets)
|
||||
}
|
||||
for _, p := range pets {
|
||||
if strings.Contains(p.Name, "Ghost") {
|
||||
t.Errorf("chased-away second pet should be hidden: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,37 +31,28 @@ func petXPToNextLevel(level int) int {
|
||||
}
|
||||
}
|
||||
|
||||
// petGrantXP adds a per-action XP grant to the pet and handles level-ups.
|
||||
// Returns true if leveled up. Shares the level-up loop with the babysit trickle
|
||||
// via advancePetLevelsFromXP.
|
||||
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
|
||||
func petGrantXP(pet *PetState) bool {
|
||||
if !pet.HasPet() {
|
||||
if !pet.HasPet() || pet.Level >= 10 {
|
||||
return false
|
||||
}
|
||||
return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100))
|
||||
}
|
||||
|
||||
// advancePetLevelsFromXP adds centi-XP to a pet and applies any level-ups, up
|
||||
// to the level-10 cap, stamping the level-10 date on first reaching it. Shared
|
||||
// by both pet slots (the babysit trickle). Returns true if the pet leveled.
|
||||
func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool {
|
||||
if *level >= 10 {
|
||||
return false
|
||||
}
|
||||
*xp += addCentiXP
|
||||
pet.XP += int(petXPPerAction * 100) // store as centixp for precision
|
||||
leveled := false
|
||||
for *level < 10 {
|
||||
needed := petXPToNextLevel(*level) * 100
|
||||
if *xp < needed {
|
||||
for pet.Level < 10 {
|
||||
needed := petXPToNextLevel(pet.Level) * 100
|
||||
if pet.XP < needed {
|
||||
break
|
||||
}
|
||||
*xp -= needed
|
||||
*level++
|
||||
pet.XP -= needed
|
||||
pet.Level++
|
||||
leveled = true
|
||||
}
|
||||
if *level >= 10 && *level10Date == "" {
|
||||
*level10Date = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
if pet.Level >= 10 && pet.Level10Date == "" {
|
||||
pet.Level10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
return leveled
|
||||
}
|
||||
|
||||
@@ -215,84 +206,28 @@ func petShouldArrive(pet PetState, house HouseState) bool {
|
||||
return rand.Float64() < 0.30
|
||||
}
|
||||
|
||||
// petShouldArrive2 gates the SECOND companion (N4/E1). It needs a Tier-4 Estate
|
||||
// and an established first pet — the second animal wanders in to join the first,
|
||||
// not an empty house. A chased-away second pet does not re-arrive (Misty's
|
||||
// reactivation is a pet-1 mechanic).
|
||||
func petShouldArrive2(pet1, pet2 PetState, house HouseState) bool {
|
||||
if pet2.Arrived || pet2.ChasedAway {
|
||||
return false
|
||||
}
|
||||
if house.Tier < 4 {
|
||||
return false
|
||||
}
|
||||
if !pet1.HasPet() {
|
||||
return false
|
||||
}
|
||||
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, 1)
|
||||
return
|
||||
}
|
||||
// A Tier-4 Estate can draw a second companion once the first is settled.
|
||||
pet2, _ := loadPet2State(userID)
|
||||
if petShouldArrive2(pet, pet2, house) {
|
||||
p.petArrivalDM(userID, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// petArrivalDM sends the initial "there's an animal in your house" DM for the
|
||||
// given slot (1 or 2).
|
||||
func (p *AdventurePlugin) petArrivalDM(userID id.UserID, slot int) {
|
||||
// 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
|
||||
if _, exists := p.pending.Load(string(userID)); exists {
|
||||
return
|
||||
}
|
||||
|
||||
intro := "There's an animal in your house. It looks like a..."
|
||||
if slot == 2 {
|
||||
intro = "There's *another* animal in your house. Your first pet seems unbothered. It looks like a..."
|
||||
}
|
||||
text := intro + "\n\n" +
|
||||
text := "There's an animal in your house. It looks like a...\n\n" +
|
||||
"🚪 Chase it away\n" +
|
||||
"🍖 Feed it\n\n" +
|
||||
"Reply with `chase` or `feed`."
|
||||
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{Slot: slot},
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
_ = p.SendDM(userID, text)
|
||||
}
|
||||
|
||||
// petSlotFromData reads a Slot off any pending-pet payload, defaulting to slot 1
|
||||
// for the zero value so older/first-pet interactions are unaffected.
|
||||
func petSlotFromData(slot int) int {
|
||||
if slot == 2 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// resolvePetArrival handles the chase/feed response.
|
||||
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -302,18 +237,13 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
slot := petSlotFromData(interaction.Data.(*advPendingPetArrival).Slot)
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
if reply == "chase" || reply == "🚪" {
|
||||
if slot == 2 {
|
||||
char.Pet2ChasedAway = true
|
||||
char.Pet2Reactivated = false
|
||||
} else {
|
||||
char.PetChasedAway = true
|
||||
char.PetReactivated = false
|
||||
}
|
||||
char.PetChasedAway = true
|
||||
char.PetReactivated = false
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.")
|
||||
}
|
||||
|
||||
@@ -327,7 +257,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{Slot: slot},
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
@@ -336,19 +266,18 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
|
||||
// Invalid response — re-prompt
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{Slot: slot},
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
|
||||
}
|
||||
|
||||
// resolvePetType handles dog/cat selection.
|
||||
func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
slot := petSlotFromData(interaction.Data.(*advPendingPetType).Slot)
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
petType := ""
|
||||
@@ -361,7 +290,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPen
|
||||
if petType == "" {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{Slot: slot},
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
|
||||
@@ -369,7 +298,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPen
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_name",
|
||||
Data: &advPendingPetName{PetType: petType, Slot: slot},
|
||||
Data: &advPendingPetName{PetType: petType},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
|
||||
@@ -381,11 +310,8 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPen
|
||||
article, titleCase(petType)))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// resolvePetName handles naming the pet.
|
||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -395,48 +321,41 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPen
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
data := interaction.Data.(*advPendingPetName)
|
||||
val, ok := p.pending.LoadAndDelete(string(ctx.Sender))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pi := val.(*advPendingInteraction)
|
||||
data := pi.Data.(*advPendingPetName)
|
||||
|
||||
name := strings.TrimSpace(ctx.Body)
|
||||
if len(name) == 0 || len(name) > 30 {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
|
||||
}
|
||||
if !petNameValid.MatchString(name) {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
|
||||
}
|
||||
|
||||
if petSlotFromData(data.Slot) == 2 {
|
||||
char.Pet2Type = data.PetType
|
||||
char.Pet2Name = name
|
||||
char.Pet2Arrived = true
|
||||
char.Pet2ChasedAway = false
|
||||
char.Pet2Level = 1
|
||||
char.Pet2XP = 0
|
||||
} else {
|
||||
char.PetType = data.PetType
|
||||
char.PetName = name
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
char.PetLevel = 1
|
||||
char.PetXP = 0
|
||||
}
|
||||
char.PetType = data.PetType
|
||||
char.PetName = name
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
char.PetLevel = 1
|
||||
char.PetXP = 0
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save.")
|
||||
}
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
|
||||
emoji := "🐶"
|
||||
if data.PetType == "cat" {
|
||||
emoji = "🐱"
|
||||
}
|
||||
tail := fmt.Sprintf("%s will join you in combat. %s will not explain their decisions.", name, name)
|
||||
if petSlotFromData(data.Slot) == 2 {
|
||||
tail = fmt.Sprintf("%s fights alongside your first companion — the two share the spotlight, so together they're about as much help in a scrap as one seasoned pet. %s will not explain their decisions.", name, name)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s",
|
||||
emoji, name, titleCase(data.PetType), tail))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s will join you in combat. %s will not explain their decisions.",
|
||||
emoji, name, titleCase(data.PetType), name, name))
|
||||
}
|
||||
|
||||
// ── Morning Pet Events ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -312,20 +312,6 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
|
||||
}
|
||||
|
||||
// N7/B3 the Omen — this week's world modifier, in TwinBee's voice. Rides the
|
||||
// existing morning DM (no net-new scheduled message); persistent one-liner so
|
||||
// a mid-week arrival still learns the active omen.
|
||||
sb.WriteString(omenMorningLine(activeOmen()))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// N7/E4 — a live season adds a "what's live this week" banner under the Omen
|
||||
// line (its themed omen already rode the line above). Same morning DM, no
|
||||
// net-new scheduled message.
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(seasonBannerLine(s))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Pick a morning greeting
|
||||
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
@@ -743,12 +729,12 @@ type AdvPlayerDaySummary struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
|
||||
Activity string
|
||||
Location string
|
||||
Outcome string
|
||||
LootValue int64
|
||||
IsDead bool
|
||||
DeadUntil string
|
||||
Activity string
|
||||
Location string
|
||||
Outcome string
|
||||
LootValue int64
|
||||
IsDead bool
|
||||
DeadUntil string
|
||||
// DeadUntilHours is the integer hours-from-now until revival, used by
|
||||
// the standout-death template's {hours} placeholder. Computed when
|
||||
// the summary row is built; 0 if not dead or already past revival.
|
||||
@@ -758,11 +744,11 @@ type AdvPlayerDaySummary struct {
|
||||
// {duration} placeholder for templates that want precision over the
|
||||
// rounded {hours} count.
|
||||
DeadUntilDuration string
|
||||
IsResting bool
|
||||
SummaryLine string
|
||||
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
|
||||
DeathSource string
|
||||
DeathLocation string
|
||||
IsResting bool
|
||||
SummaryLine string
|
||||
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
|
||||
DeathSource string
|
||||
DeathLocation string
|
||||
}
|
||||
|
||||
// advPlayerHeadline renders the per-player headline for the daily report.
|
||||
@@ -934,6 +920,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
|
||||
// Standout
|
||||
if bestPlayer != nil && bestPlayer.LootValue > 0 {
|
||||
pool := SummaryStandoutGood
|
||||
@@ -988,7 +975,6 @@ type AdvLeaderboardEntry struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
CurrentStreak int
|
||||
Renown int // N7/B2 — prestige level, cosmetic marker only
|
||||
}
|
||||
|
||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
@@ -998,25 +984,20 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
|
||||
// Sort by score
|
||||
type entry struct {
|
||||
Name string
|
||||
Score int
|
||||
Name string
|
||||
Score int
|
||||
Levels string
|
||||
Streak int
|
||||
Renown int
|
||||
}
|
||||
var entries []entry
|
||||
for _, c := range chars {
|
||||
// Renown adds to the ranking score so a capped, prestigious player still
|
||||
// climbs — it's the only progression they have left past L20.
|
||||
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||
score += c.Renown * 10
|
||||
name, _ := loadDisplayName(c.UserID)
|
||||
entries = append(entries, entry{
|
||||
Name: name,
|
||||
Score: score,
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||
Streak: c.CurrentStreak,
|
||||
Renown: c.Renown,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1048,11 +1029,7 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
if e.Streak >= 7 {
|
||||
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
||||
}
|
||||
renownBadge := ""
|
||||
if m := renownMarker(e.Renown); m != "" {
|
||||
renownBadge = " " + m
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s**%s — %s (score: %d%s)\n", prefix, e.Name, renownBadge, e.Levels, e.Score, streak))
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -1061,17 +1038,8 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
||||
|
||||
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
||||
// The flavor templates below are written for the base 3-slot set. With a
|
||||
// T3 trophy room the set can hold 4, so fall back to a plain dynamic list
|
||||
// whenever it isn't exactly 3 — every slot has to be visible to choose it.
|
||||
if len(TreasureInventoryCap) == 0 || len(existing) != 3 {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "You found **%s** but your treasure slots are full. Discard one to make room:\n", newTreasure.Name)
|
||||
for i, ex := range existing {
|
||||
fmt.Fprintf(&b, "%d. %s — %s\n", i+1, ex.Name, ex.InventoryDesc)
|
||||
}
|
||||
fmt.Fprintf(&b, "\nReply with the number to discard, or `keep` to leave the new one behind.")
|
||||
return b.String()
|
||||
if len(TreasureInventoryCap) == 0 {
|
||||
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
|
||||
}
|
||||
|
||||
// Build substitution map with existing treasure info
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// N7/B2 — Renown: prestige past the level cap (gogobee_engagement_plan.md §B2).
|
||||
//
|
||||
// A confirmed D&D character caps at L20 (dndMaxLevel). Before N7, grantDnDXP
|
||||
// silently dropped any XP earned past the cap. Renown reclaims that overflow as
|
||||
// a prestige track: every renownXPPerLevel of overflow is one Renown level.
|
||||
//
|
||||
// Storage is a single cumulative column, player_meta.renown_xp, written by an
|
||||
// atomic INSERT…ON CONFLICT … += (the journal_pages pattern) so there is no
|
||||
// read-modify-write race. renown_level is DERIVED (renownLevelFor) rather than
|
||||
// stored, so nothing can disagree with the XP total.
|
||||
//
|
||||
// The reward is deliberately prestige-only — a derived rank title, a cosmetic
|
||||
// marker on the sheet/leaderboard, and a small capped bundle of *activity*
|
||||
// bonuses (loot quality, XP, death avoidance). It NEVER grants combat stats:
|
||||
// the balance corpus (SimulateCombat / the golden) must stay valid, so renown
|
||||
// touches only the AdvBonusSummary activity levers and only at the activity
|
||||
// call sites, never loadCombatBonuses.
|
||||
|
||||
// renownXPPerLevel is the overflow XP that buys one Renown level. Steep by
|
||||
// design (§B2): a capped L20 player earns ~750 XP per T5 dungeon win, so a
|
||||
// Renown level is dozens of endgame clears.
|
||||
const renownXPPerLevel = 25000
|
||||
|
||||
// Renown perk ladder. One small step every renownPerkStepLevels Renown levels,
|
||||
// capped at renownPerkMaxSteps steps. At the cap the perks total +20% XP / +15%
|
||||
// loot, matching a streak-30 grant's economic half — §B2's ceiling in total
|
||||
// power.
|
||||
//
|
||||
// The perks are deliberately ONLY the two combat-neutral levers of the
|
||||
// AdvBonusSummary: combat_stats.go maps DeathModifier→Defense,
|
||||
// SuccessBonus→Attack and ExceptionalBonus→CritRate, so those *are* combat
|
||||
// stats and are off-limits (§B2: never combat-stat inflation, the balance
|
||||
// corpus must stay valid). LootQuality and XPMultiplier are read only by the
|
||||
// loot/XP economy, never by combat stat derivation — so renown can pay them out
|
||||
// even through loadCombatBonuses without moving the golden. That is why §B2's
|
||||
// suggested "−death penalty" perk is intentionally NOT granted: it would inflate
|
||||
// Defense.
|
||||
const (
|
||||
renownPerkStepLevels = 3
|
||||
renownPerkMaxSteps = 10
|
||||
)
|
||||
|
||||
// renownLevelFor derives the Renown level from cumulative overflow XP.
|
||||
func renownLevelFor(renownXP int) int {
|
||||
if renownXP <= 0 {
|
||||
return 0
|
||||
}
|
||||
return renownXP / renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownXPIntoLevel returns (progress, cost) toward the next Renown level, for
|
||||
// display. cost is always renownXPPerLevel.
|
||||
func renownXPIntoLevel(renownXP int) (int, int) {
|
||||
if renownXP < 0 {
|
||||
renownXP = 0
|
||||
}
|
||||
return renownXP % renownXPPerLevel, renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownRank is one rung of the derived title ladder: the first Renown level at
|
||||
// which the rank applies, and its name. A rank promotion (crossing into a new
|
||||
// rung) is what gets announced in the games room; plain level-ups only DM.
|
||||
type renownRank struct {
|
||||
MinLevel int
|
||||
Name string
|
||||
}
|
||||
|
||||
var renownRanks = []renownRank{
|
||||
{1, "Renowned"},
|
||||
{3, "Storied"},
|
||||
{5, "Illustrious"},
|
||||
{10, "Fabled"},
|
||||
{15, "Mythic"},
|
||||
{20, "Ascendant"},
|
||||
{30, "Eternal"},
|
||||
}
|
||||
|
||||
// renownRankFor returns the rank name for a Renown level, or "" below level 1.
|
||||
func renownRankFor(level int) string {
|
||||
name := ""
|
||||
for _, r := range renownRanks {
|
||||
if level >= r.MinLevel {
|
||||
name = r.Name
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// renownMarker is the cosmetic prestige badge shown next to a name on the sheet
|
||||
// and leaderboard. Empty below Renown 1.
|
||||
func renownMarker(level int) string {
|
||||
if level < 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("✦%d", level)
|
||||
}
|
||||
|
||||
// applyRenownBonuses folds the capped renown perks into an AdvBonusSummary. It
|
||||
// touches ONLY LootQuality and XPMultiplier — the combat-neutral economy levers
|
||||
// — so it is safe to call anywhere the summary is built, including
|
||||
// loadCombatBonuses, without changing any combat stat (see the const block).
|
||||
func applyRenownBonuses(b *AdvBonusSummary, renownLevel int) {
|
||||
if b == nil || renownLevel < renownPerkStepLevels {
|
||||
return
|
||||
}
|
||||
steps := renownLevel / renownPerkStepLevels
|
||||
if steps > renownPerkMaxSteps {
|
||||
steps = renownPerkMaxSteps
|
||||
}
|
||||
b.XPMultiplier += float64(steps) * 2 // → +20% at the cap
|
||||
b.LootQuality += float64(steps) * 1.5 // → +15% at the cap
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
// renownAccrueSQL is the atomic cumulative += on player_meta.renown_xp (the
|
||||
// journal_pages pattern), returning the post-update total. Shared by the
|
||||
// standalone and transactional accrual paths so the SQL lives in one place.
|
||||
const renownAccrueSQL = `INSERT INTO player_meta (user_id, renown_xp) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET renown_xp = renown_xp + excluded.renown_xp
|
||||
RETURNING renown_xp`
|
||||
|
||||
// sqlQueryer is the subset of *sql.DB / *sql.Tx that accrueRenownXP needs, so
|
||||
// the accrual can run standalone or inside grantDnDXP's save transaction.
|
||||
type sqlQueryer interface {
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// accrueRenownXP adds delta (> 0) to renown_xp against any queryer and returns
|
||||
// the cumulative totals before and after. A single statement, safe against
|
||||
// concurrent grants — no lost update.
|
||||
func accrueRenownXP(q sqlQueryer, userID id.UserID, delta int) (before, after int, err error) {
|
||||
if err = q.QueryRow(renownAccrueSQL, string(userID), delta).Scan(&after); err != nil {
|
||||
return 0, 0, fmt.Errorf("accrue renown_xp: %w", err)
|
||||
}
|
||||
return after - delta, after, nil
|
||||
}
|
||||
|
||||
// addRenownXP is the standalone accrual (its own DB write). delta <= 0 is a
|
||||
// no-op read. grantDnDXP uses the transactional saveDnDCharacterWithOverflow
|
||||
// instead, so this is the API for callers not already saving a character.
|
||||
func addRenownXP(userID id.UserID, delta int) (before, after int, err error) {
|
||||
if delta <= 0 {
|
||||
cur, e := loadRenownXP(userID)
|
||||
return cur, cur, e
|
||||
}
|
||||
return accrueRenownXP(db.Get(), userID, delta)
|
||||
}
|
||||
|
||||
// saveDnDCharacterWithOverflow persists the character and converts overflow XP
|
||||
// (> 0) to Renown in one transaction, so a crash can neither drop the overflow
|
||||
// nor double-credit it. Returns the cumulative renown totals before/after.
|
||||
func saveDnDCharacterWithOverflow(c *DnDCharacter, overflow int) (before, after int, err error) {
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = saveDnDCharacterExec(tx, c); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if before, after, err = accrueRenownXP(tx, c.UserID, overflow); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return before, after, nil
|
||||
}
|
||||
|
||||
// loadRenownXP reads the cumulative overflow XP. Absent row == 0.
|
||||
func loadRenownXP(userID id.UserID) (int, error) {
|
||||
var xp int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return xp, err
|
||||
}
|
||||
|
||||
// renownLevelForUser is the per-user Renown level, for callers (leaderboard)
|
||||
// that don't already hold the overlay. Errors resolve to 0.
|
||||
func renownLevelForUser(userID id.UserID) int {
|
||||
xp, err := loadRenownXP(userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(xp)
|
||||
}
|
||||
|
||||
// ── Announce ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// announceRenown notifies the player of Renown level-ups (DM) and, when the
|
||||
// gain crossed into a new rank rung, the games room. Event-driven off a grant,
|
||||
// not a scheduled recap. from/to are Renown levels before/after the grant.
|
||||
func (p *AdventurePlugin) announceRenown(userID id.UserID, from, to int) {
|
||||
if p == nil || to <= from {
|
||||
return
|
||||
}
|
||||
gained := to - from
|
||||
rank := renownRankFor(to)
|
||||
if p.Client != nil {
|
||||
msg := fmt.Sprintf("✦ **Renown %d** ✦\n\nYou've earned %s past the level cap — your legend grows.",
|
||||
to, pluralLevels(gained))
|
||||
if rank != "" {
|
||||
msg += fmt.Sprintf("\nRank: **%s**.", rank)
|
||||
}
|
||||
if err := p.SendDM(userID, msg); err != nil {
|
||||
slog.Error("renown: level-up DM failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
// Games-room shout only on a rank promotion, to keep the room quiet.
|
||||
if renownRankFor(from) == rank {
|
||||
return
|
||||
}
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
name, _ := loadDisplayName(userID)
|
||||
if name == "" {
|
||||
name = string(userID)
|
||||
}
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"✦ **%s** reached **Renown %d — %s.** A legend of the realm.", name, to, rank))
|
||||
}
|
||||
|
||||
func pluralLevels(n int) string {
|
||||
if n == 1 {
|
||||
return "a Renown level"
|
||||
}
|
||||
return fmt.Sprintf("%d Renown levels", n)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newRenownTestDB(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 TestRenownLevelFor(t *testing.T) {
|
||||
cases := []struct{ xp, want int }{
|
||||
{0, 0},
|
||||
{-100, 0},
|
||||
{renownXPPerLevel - 1, 0},
|
||||
{renownXPPerLevel, 1},
|
||||
{renownXPPerLevel*3 + 12, 3},
|
||||
{renownXPPerLevel * 30, 30},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := renownLevelFor(c.xp); got != c.want {
|
||||
t.Errorf("renownLevelFor(%d) = %d, want %d", c.xp, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownXPIntoLevel(t *testing.T) {
|
||||
into, cost := renownXPIntoLevel(renownXPPerLevel + 500)
|
||||
if cost != renownXPPerLevel {
|
||||
t.Errorf("cost = %d, want %d", cost, renownXPPerLevel)
|
||||
}
|
||||
if into != 500 {
|
||||
t.Errorf("into = %d, want 500", into)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownRankLadderMonotonic(t *testing.T) {
|
||||
// Rank thresholds must strictly increase so renownRankFor is well-defined.
|
||||
for i := 1; i < len(renownRanks); i++ {
|
||||
if renownRanks[i].MinLevel <= renownRanks[i-1].MinLevel {
|
||||
t.Errorf("rank %d threshold %d not > %d", i, renownRanks[i].MinLevel, renownRanks[i-1].MinLevel)
|
||||
}
|
||||
}
|
||||
if renownRankFor(0) != "" {
|
||||
t.Errorf("rank at 0 should be empty")
|
||||
}
|
||||
if renownRankFor(1) != "Renowned" {
|
||||
t.Errorf("rank at 1 = %q, want Renowned", renownRankFor(1))
|
||||
}
|
||||
if renownRankFor(4) != "Storied" {
|
||||
t.Errorf("rank at 4 = %q, want Storied (threshold 3)", renownRankFor(4))
|
||||
}
|
||||
if renownRankFor(1000) != "Eternal" {
|
||||
t.Errorf("top rank = %q, want Eternal", renownRankFor(1000))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownMarker(t *testing.T) {
|
||||
if renownMarker(0) != "" {
|
||||
t.Errorf("marker at 0 should be empty")
|
||||
}
|
||||
if got := renownMarker(7); got != "✦7" {
|
||||
t.Errorf("marker(7) = %q, want ✦7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRenownBonuses_CombatNeutralAndCapped — renown grants only the two
|
||||
// combat-neutral economy levers (loot/XP), capped at +15%/+20%, and must never
|
||||
// touch a lever that combat_stats.go maps to a combat stat. §B2.
|
||||
func TestApplyRenownBonuses_CombatNeutralAndCapped(t *testing.T) {
|
||||
// Below the first step: no effect.
|
||||
b := &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 2)
|
||||
if b.XPMultiplier != 0 || b.LootQuality != 0 {
|
||||
t.Errorf("renown < step should be inert, got %+v", b)
|
||||
}
|
||||
|
||||
// One step at renown 3.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 3)
|
||||
if b.XPMultiplier != 2 || b.LootQuality != 1.5 {
|
||||
t.Errorf("one step wrong: %+v", b)
|
||||
}
|
||||
|
||||
// At renown 30 and beyond, capped at +20% XP / +15% loot.
|
||||
for _, lvl := range []int{30, 45, 300} {
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, lvl)
|
||||
if b.XPMultiplier != 20 || b.LootQuality != 15 {
|
||||
t.Errorf("renown %d not capped: %+v", lvl, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Never combat-stat inflation: the levers combat_stats.go reads
|
||||
// (DeathModifier→Defense, SuccessBonus→Attack, ExceptionalBonus→CritRate)
|
||||
// and the skill/combat levers must all stay at zero at any renown level.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 300)
|
||||
if b.DeathModifier != 0 || b.SuccessBonus != 0 || b.ExceptionalBonus != 0 {
|
||||
t.Errorf("renown leaked into a combat-stat lever: %+v", b)
|
||||
}
|
||||
if b.CombatBonus != 0 || b.MiningBonus != 0 || b.ForagingBonus != 0 || b.FishingBonus != 0 {
|
||||
t.Errorf("renown leaked into combat/skill levers: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRenownXP_Accumulates — cumulative += with correct before/after.
|
||||
func TestAddRenownXP_Accumulates(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_add:example")
|
||||
if err := createAdvCharacter(uid, "Renowner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, after, err := addRenownXP(uid, 10000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 0 || after != 10000 {
|
||||
t.Errorf("first add: before=%d after=%d, want 0/10000", before, after)
|
||||
}
|
||||
|
||||
before, after, err = addRenownXP(uid, 20000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 10000 || after != 30000 {
|
||||
t.Errorf("second add: before=%d after=%d, want 10000/30000", before, after)
|
||||
}
|
||||
|
||||
got, err := loadRenownXP(uid)
|
||||
if err != nil || got != 30000 {
|
||||
t.Errorf("loadRenownXP = %d (err %v), want 30000", got, err)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renownLevelForUser = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownAtLeast — the B4 achievement gate reads the derived level correctly.
|
||||
func TestRenownAtLeast(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_ach:example")
|
||||
if err := createAdvCharacter(uid, "Achiever"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := db.Get()
|
||||
if renownAtLeast(d, uid, 1) {
|
||||
t.Errorf("no renown yet, should not meet level 1")
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !renownAtLeast(d, uid, 5) {
|
||||
t.Errorf("renown 5 should meet level 5")
|
||||
}
|
||||
if renownAtLeast(d, uid, 6) {
|
||||
t.Errorf("renown 5 should not meet level 6")
|
||||
}
|
||||
// Absent player → false, not a crash.
|
||||
if renownAtLeast(d, id.UserID("@nobody:nowhere.invalid"), 1) {
|
||||
t.Errorf("absent player should not meet any renown level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownOverlay — applyPlayerMetaOverlay populates RenownXP and RenownLevel.
|
||||
func TestRenownOverlay(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_overlay:example")
|
||||
if err := createAdvCharacter(uid, "Overlaid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5+7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := loadAdvCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if c.RenownXP != renownXPPerLevel*5+7 {
|
||||
t.Errorf("overlay RenownXP = %d, want %d", c.RenownXP, renownXPPerLevel*5+7)
|
||||
}
|
||||
if c.RenownLevel() != 5 {
|
||||
t.Errorf("RenownLevel() = %d, want 5", c.RenownLevel())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantDnDXP_OverflowBecomesRenown — at the cap, grantDnDXP routes overflow
|
||||
// into renown_xp and zeroes dnd_xp (the pre-N7 cap invariant is preserved).
|
||||
func TestGrantDnDXP_OverflowBecomesRenown(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_grant:example")
|
||||
if err := createAdvCharacter(uid, "Capped"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: dndMaxLevel,
|
||||
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12, ArmorClass: 16,
|
||||
}
|
||||
c.HPMax = 100
|
||||
c.HPCurrent = 100
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
events, err := p.grantDnDXP(uid, renownXPPerLevel+5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("got %d level-up events at cap, want 0", len(events))
|
||||
}
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Level != dndMaxLevel {
|
||||
t.Errorf("level = %d, want %d", got.Level, dndMaxLevel)
|
||||
}
|
||||
if got.XP != 0 {
|
||||
t.Errorf("dnd_xp = %d, want 0 (overflow moved to renown)", got.XP)
|
||||
}
|
||||
if xp, _ := loadRenownXP(uid); xp != renownXPPerLevel+5000 {
|
||||
t.Errorf("renown_xp = %d, want %d", xp, renownXPPerLevel+5000)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renown level = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
@@ -164,43 +164,21 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
gaveCard = true
|
||||
}
|
||||
|
||||
// Update visit count, and every 10th visit leave a small consumable
|
||||
// "for the trouble" (D2 NPC arc).
|
||||
var leftGift *AdvItem
|
||||
// Update visit count
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil {
|
||||
char.RobbieVisitCount++
|
||||
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
|
||||
// Use the canonical DnD level (like the arena's tier gate), not the
|
||||
// frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup
|
||||
// completes, so reading it here would peg every gift at tier 1.
|
||||
if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 {
|
||||
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
||||
leftGift = &gifts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char))
|
||||
}
|
||||
|
||||
// Send DM
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGift)
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
|
||||
if err := p.SendDM(userID, dm); err != nil {
|
||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
// Room announcement — but not for a player who isn't there.
|
||||
//
|
||||
// A bored adventurer (gogobee_boredom_plan.md) auto-harvests ore and junk
|
||||
// into inventory on every run, and Robbie takes all of it, every day. Left
|
||||
// alone, each abandoned character would file a public bulletin every single
|
||||
// day, indefinitely, about somebody who stopped playing weeks ago. He still
|
||||
// visits and still pays — that income is what keeps the neglected adventurer
|
||||
// walking — he just doesn't announce a house with nobody in it.
|
||||
if playerIsIdle(userID, time.Now().UTC()) {
|
||||
return
|
||||
}
|
||||
// Room announcement
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)
|
||||
@@ -213,12 +191,10 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||
var result []AdvItem
|
||||
for _, item := range inv {
|
||||
// Never touch Arena gear, cards, consumables, or keys. Consumables are
|
||||
// a player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves. Keys are cross-zone
|
||||
// unlock tokens (N5/D4) that must persist in inventory to open their
|
||||
// vault later — sweeping one permanently breaks that unlock.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" {
|
||||
// Never touch Arena gear, cards, or consumables. Consumables are a
|
||||
// player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -267,27 +243,7 @@ func robbiePlayerHasCard(userID id.UserID) bool {
|
||||
|
||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind.
|
||||
const robbieGiftEveryNVisits = 10
|
||||
|
||||
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
||||
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
||||
func robbieGiftTier(level int) int {
|
||||
switch {
|
||||
case level >= 18:
|
||||
return 5
|
||||
case level >= 13:
|
||||
return 4
|
||||
case level >= 8:
|
||||
return 3
|
||||
case level >= 4:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGift *AdvItem) string {
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Opening
|
||||
@@ -334,12 +290,6 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Every-10th-visit consumable (D2).
|
||||
if leftGift != nil {
|
||||
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Closing
|
||||
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
|
||||
sb.WriteString(closing)
|
||||
|
||||
@@ -82,12 +82,6 @@ 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
|
||||
@@ -139,12 +133,13 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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 arrival check (fires before normal morning DM)
|
||||
house, _ := loadHouseState(char.UserID)
|
||||
pet, _ := loadPetState(char.UserID)
|
||||
if petShouldArrive(pet, house) {
|
||||
p.petArrivalDM(char.UserID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Morning pet event
|
||||
petEvent := petMorningEvent(pet)
|
||||
@@ -163,7 +158,6 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
holidayLabel := ""
|
||||
@@ -390,9 +384,6 @@ 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
|
||||
}
|
||||
@@ -400,111 +391,88 @@ 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)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
today := now.Format("2006-01-02")
|
||||
yesterday := now.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)
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
|
||||
dmsSent := 0
|
||||
for _, char := range chars {
|
||||
// Advance this player's Shadow (N6/D3) once for the day, before any
|
||||
// streak/idle branching below — the rival runs whether or not the
|
||||
// player did, which is the whole point of the race pressure. Own table,
|
||||
// own idempotency guard; never touches char, never fails the reset.
|
||||
p.advanceShadow(&char)
|
||||
if !char.HasActedToday() {
|
||||
// If the player died today or yesterday, they couldn't act — no shame,
|
||||
// no streak reset. This covers both currently-dead players and players
|
||||
// who were just revived at midnight (Alive already flipped to true by
|
||||
// the reminder loop before midnightReset runs).
|
||||
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
||||
continue
|
||||
}
|
||||
|
||||
// Died inside the window — no shame, no streak change. Covers both
|
||||
// currently-dead players and players revived at midnight (Alive
|
||||
// already flipped to true by the reminder loop before this runs).
|
||||
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
||||
continue
|
||||
}
|
||||
// An active expedition — or a turn-based fight locked open across
|
||||
// midnight — counts as activity. Both track their own action flow
|
||||
// (zone/harvest/combat/transit/extract) and never touch the legacy
|
||||
// CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday()
|
||||
// reports false. Treat them like the acted-today branch below:
|
||||
// advance the streak and bail out (no idle-shame, no streak decay).
|
||||
busy := false
|
||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
||||
} else if exp != nil {
|
||||
busy = true
|
||||
}
|
||||
if !busy && hasActiveCombatSession(char.UserID) {
|
||||
busy = true
|
||||
}
|
||||
if busy {
|
||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else {
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
continue
|
||||
}
|
||||
|
||||
// Player-initiated engagement — only this credits the streak.
|
||||
// LastActionDate is stamped at action time by markActedToday + !rest;
|
||||
// the legacy counters get bumped by the legacy CanDo... paths.
|
||||
engaged := char.LastActionDate == today || char.LastActionDate == yesterday ||
|
||||
char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0
|
||||
// Jitter between DMs to avoid Matrix rate limits
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
|
||||
if engaged {
|
||||
// Idle shame DM
|
||||
text := renderAdvIdleShameDM(char.UserID)
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
char.CurrentStreak /= 2
|
||||
char.StreakDecayed = true
|
||||
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
|
||||
if char.CurrentStreak > 0 {
|
||||
text += " — not all is lost."
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
} else {
|
||||
// Update streak — LastActionDate was set at action time
|
||||
if 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.
|
||||
// Gap in activity — start fresh
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
continue
|
||||
}
|
||||
|
||||
// Every hold below rests on one premise: the player engaged earlier to
|
||||
// kick this off, so the autopilot finishing the job shouldn't cost them
|
||||
// their streak. A boredom expedition has no such origin — nobody kicked
|
||||
// it off, and its player still hasn't come back. It earns them nothing
|
||||
// and shields them from nothing (gogobee_boredom_plan.md §6).
|
||||
if !isBoredomDriven(char.UserID, now) {
|
||||
// Activity happened on the player's behalf without an explicit tap —
|
||||
// autopilot walked rooms, expedition log gained beats, a fight session
|
||||
// is still locked open. Hold the streak: no bump, no shame, no decay.
|
||||
// The player engaged earlier to kick this off; autopilot is a feature,
|
||||
// not a way to game streaks, and absence of taps shouldn't strip
|
||||
// progress they earned through real play.
|
||||
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
|
||||
continue
|
||||
}
|
||||
// Safety net for live state the activity logs don't reflect yet
|
||||
// (e.g. an active expedition that's been quiet today, or a combat
|
||||
// session locked open across midnight without a log append).
|
||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
||||
} else if exp != nil {
|
||||
continue
|
||||
}
|
||||
if hasActiveCombatSession(char.UserID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Truly idle — shame DM + streak halve.
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
|
||||
text := renderAdvIdleShameDM(char.UserID)
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
char.CurrentStreak /= 2
|
||||
char.StreakDecayed = true
|
||||
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
|
||||
if char.CurrentStreak > 0 {
|
||||
text += " — not all is lost."
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,12 +490,6 @@ 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)
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/E4 — Seasonal events (gogobee_engagement_plan.md §E4).
|
||||
//
|
||||
// A season is a 1-week skin anchored to a real holiday. Unlike isHolidayToday()
|
||||
// (a single UTC day, worth a double-action) and unlike the Omen (an ISO-week
|
||||
// rotation), a season spans a window around its anchor date and layers three
|
||||
// things on top of the world for that week:
|
||||
//
|
||||
// 1. A themed world modifier — a reskinned Omen that OVERRIDES the weekly
|
||||
// rotation (see activeOmen). It reuses the omen effect fields, so the same
|
||||
// non-combat rule holds: a season never touches SimulateCombat or the turn
|
||||
// engine. That is enforced structurally — activeSeason() honours
|
||||
// simOmenDisabled exactly as activeOmen() does, so the balance sim never
|
||||
// sees a season through any path, and the season Omen only reaches the sim's
|
||||
// omen seams behind activeOmen's own simOmenDisabled guard.
|
||||
// 2. A limited-time curio shelf at Luigi's — a curated selection of existing
|
||||
// registry items (dailyCuriosStock). No new power enters the game; the shelf
|
||||
// just changes which items rotate in for the week.
|
||||
// 3. A themed visitor on the road — a season-gated ambient event that leaves a
|
||||
// small keepsake and a coin gift (expedition_ambient.go). No combat: the
|
||||
// ambient seam has never opened a fight and this does not change that.
|
||||
//
|
||||
// A season is a pure function of the UTC date and the anchor calendar, so it
|
||||
// needs no schema, no ticker state, and no persistence — the same discipline as
|
||||
// the Omen and the holiday calendar.
|
||||
|
||||
// seasonHalfWidth is the number of days on each side of the anchor date that the
|
||||
// season is live. 3 → a 7-day window (anchor ± 3).
|
||||
const seasonHalfWidth = 3
|
||||
|
||||
// seasonVisitor is the themed ambient event a season adds to the road. It never
|
||||
// resolves combat — it leaves a sellable keepsake and a small coin gift.
|
||||
type seasonVisitor struct {
|
||||
Weight int // relative weight in the ambient pick
|
||||
FlavorPool []string // scene narration lines for the visit
|
||||
Keepsake string // sellable trophy left behind
|
||||
KeepsakeValue int64 // coin baseline of the keepsake
|
||||
Coins int // flat coin gift
|
||||
}
|
||||
|
||||
// season is one holiday skin.
|
||||
type season struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing, e.g. "Hallowtide"
|
||||
Emoji string // banner emoji
|
||||
Anchor func(year int) time.Time // the anchor date for a given year (UTC)
|
||||
Blurb string // one-line "what's live this week" banner copy
|
||||
Omen omen // themed override omen (non-combat fields only)
|
||||
CurioIDs []string // curated registry IDs for Luigi's shelf
|
||||
Visitor seasonVisitor // the road visitor
|
||||
}
|
||||
|
||||
// fixedAnchor builds an Anchor for a fixed month/day holiday.
|
||||
func fixedAnchor(month time.Month, day int) func(int) time.Time {
|
||||
return func(year int) time.Time {
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
// seasonTable is the set of anchor holidays that get a skin. Order is match
|
||||
// order; the windows are disjoint so it never matters, but keep them disjoint.
|
||||
var seasonTable = []season{
|
||||
{
|
||||
Key: "hallowtide", Name: "Hallowtide", Emoji: "🎃",
|
||||
Anchor: fixedAnchor(time.October, 31),
|
||||
Blurb: "the veil's thin — spooky curios at Luigi's and things going bump on the road, all week",
|
||||
Omen: omen{
|
||||
Key: "hallowtide", Name: "Hallowtide",
|
||||
TwinBee: "The veil's gone thin for Hallowtide — reagents and oddments are spilling through from somewhere I'd rather not name. I'm grabbing them while they're here.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"cloak_of_arachnida", "demon_armor", "goggles_of_night",
|
||||
"slippers_of_spider_climbing", "staff_of_swarming_insects",
|
||||
"sword_of_life_stealing", "dagger_of_venom", "cloak_of_the_bat",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A gourd-headed thing shuffles out of the dark, sets a little carved lantern on your bedroll, and shuffles right back into it.",
|
||||
"Something small and many-legged skitters past, drops a trinket at your feet as if in tribute, and is gone before you can look twice.",
|
||||
"A cold draft carries a whisper and a gift — a carved gourd, still faintly warm, left where you'll find it come morning.",
|
||||
},
|
||||
Keepsake: "Carved Gourd Lantern", KeepsakeValue: 60, Coins: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "midwinter", Name: "Midwinter Feast", Emoji: "❄️",
|
||||
Anchor: fixedAnchor(time.December, 25),
|
||||
Blurb: "gifts by every door — a free pack for anyone heading out, warm curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "midwinter", Name: "Midwinter Feast",
|
||||
TwinBee: "It's Midwinter, and someone's been leaving gifts by the outfitter's door. There's a free pack in it for anyone heading out — and I set off in high spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"boots_of_the_winterlands", "frost_brand", "ring_of_warmth",
|
||||
"staff_of_frost", "potion_of_healing", "staff_of_healing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A bundled figure passes your camp without a word, leaves a frost-glass bauble hanging where the firelight catches it, and trudges on into the snow.",
|
||||
"You wake to find your pack a little heavier — a wrapped bauble and a handful of coin, and a single set of bootprints leading away.",
|
||||
"Bells, faint and far off. By the time they fade there's a bauble on your bedroll that wasn't there before.",
|
||||
},
|
||||
Keepsake: "Frost-Glass Bauble", KeepsakeValue: 60, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel", Emoji: "💗",
|
||||
Anchor: fixedAnchor(time.February, 14),
|
||||
Blurb: "the arena crowd's throwing coin — fat purses, charming curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel",
|
||||
TwinBee: "The whole town's giddy for Sweethearts' week — the arena crowd's throwing coin like confetti. Win pretty and the purse pays twenty over the odds.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"eyes_of_charming", "philter_of_love", "staff_of_charming",
|
||||
"luck_blade", "stone_of_good_luck_luckstone", "pearl_of_power",
|
||||
"glamoured_studded_leather",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A courier in festival colours finds you even out here, presses a ribbon-wrapped token into your hand with a wink, and hurries back the way they came.",
|
||||
"A paper heart, pinned to a token and left on your pack — 'from an admirer,' it says, and nothing else.",
|
||||
"Someone's left a ribbon-wrapped keepsake by the trail marker, addressed to no one and everyone. You pocket it.",
|
||||
},
|
||||
Keepsake: "Ribbon-Wrapped Token", KeepsakeValue: 55, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "first_bloom", Name: "First Bloom", Emoji: "🌷",
|
||||
Anchor: func(year int) time.Time { return easterDate(year) },
|
||||
Blurb: "everything's growing eager — fuller harvests, green curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "first_bloom", Name: "First Bloom",
|
||||
TwinBee: "First Bloom's on us — everything's growing twice as eager and the woods feel calm with it. Every gather comes up fuller, and the dread's slow to rise.",
|
||||
HarvestYieldBonus: 1,
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"bag_of_beans", "potion_of_growth", "potion_of_animal_friendship",
|
||||
"ring_of_animal_influence", "horn_of_valhalla", "sword_of_life_stealing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A hare the size of a hound lopes up, regards you with unsettling calm, and leaves a pressed blossom on the ground before bounding off.",
|
||||
"New vines have crept over your gear in the night — and tucked among them, a perfect pressed blossom and a little coin, as if the woods were paying rent.",
|
||||
"The undergrowth parts on its own, sets a spring blossom at your feet, and closes again. You decide not to question it.",
|
||||
},
|
||||
Keepsake: "Pressed Spring Blossom", KeepsakeValue: 50, Coins: 8,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// activeSeason returns the season whose window contains today (UTC), or false.
|
||||
// Honours simOmenDisabled so the balance sim never observes a season through any
|
||||
// path — the same neutralisation activeOmen applies to the weekly rotation.
|
||||
func activeSeason() (season, bool) {
|
||||
if simOmenDisabled {
|
||||
return season{}, false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return seasonForDate(today)
|
||||
}
|
||||
|
||||
// seasonForDate is the pure core of activeSeason, split out for tests. The
|
||||
// anchor is checked against the target year and its neighbours so a window that
|
||||
// straddles a year boundary still resolves.
|
||||
func seasonForDate(today time.Time) (season, bool) {
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{today.Year() - 1, today.Year(), today.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
lo := a.AddDate(0, 0, -seasonHalfWidth)
|
||||
hi := a.AddDate(0, 0, seasonHalfWidth)
|
||||
if !today.Before(lo) && !today.After(hi) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return season{}, false
|
||||
}
|
||||
|
||||
// seasonBannerLine is the "what's live this week" one-liner surfaced under the
|
||||
// Omen line in the morning DM. Empty when no season is active.
|
||||
func seasonBannerLine(s season) string {
|
||||
return s.Emoji + " **" + s.Name + "** is here — " + s.Blurb + "."
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func date(y int, m time.Month, d int) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// TestSeasonForDate_ActiveOnAnchor — every season resolves on its own anchor day
|
||||
// and both window edges (±seasonHalfWidth), and is dark one day past each edge.
|
||||
func TestSeasonForDate_ActiveOnAnchor(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
a := s.Anchor(2026)
|
||||
for _, off := range []int{-seasonHalfWidth, 0, seasonHalfWidth} {
|
||||
got, ok := seasonForDate(a.AddDate(0, 0, off))
|
||||
if !ok || got.Key != s.Key {
|
||||
t.Errorf("%s: day offset %d → (%v, %q), want (true, %q)",
|
||||
s.Key, off, ok, got.Key, s.Key)
|
||||
}
|
||||
}
|
||||
for _, off := range []int{-seasonHalfWidth - 1, seasonHalfWidth + 1} {
|
||||
if got, ok := seasonForDate(a.AddDate(0, 0, off)); ok {
|
||||
t.Errorf("%s: day offset %d should be dark, got %q", s.Key, off, got.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonForDate_WindowsDisjoint — no calendar day resolves to two seasons, so
|
||||
// the first-match order in seasonForDate never hides overlapping content.
|
||||
func TestSeasonForDate_WindowsDisjoint(t *testing.T) {
|
||||
for y := 2024; y <= 2030; y++ {
|
||||
day := date(y, time.January, 1)
|
||||
end := date(y+1, time.January, 1)
|
||||
for day.Before(end) {
|
||||
matches := 0
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{day.Year() - 1, day.Year(), day.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
if !day.Before(a.AddDate(0, 0, -seasonHalfWidth)) &&
|
||||
!day.After(a.AddDate(0, 0, seasonHalfWidth)) {
|
||||
matches++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches > 1 {
|
||||
t.Fatalf("%s resolves to %d seasons (windows must be disjoint)",
|
||||
day.Format("2006-01-02"), matches)
|
||||
}
|
||||
day = day.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_CuratedItemsExist — every curated curio ID is a real registry
|
||||
// item, so a season's shelf never lists a phantom the buyer can't purchase. Guards
|
||||
// against typos and registry edits (mirrors TestTemperMaterialsAreObtainable).
|
||||
func TestSeasonTable_CuratedItemsExist(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
if len(s.CurioIDs) == 0 {
|
||||
t.Errorf("%s has no curated curios", s.Key)
|
||||
}
|
||||
for _, id := range s.CurioIDs {
|
||||
if _, ok := magicItemRegistry[id]; !ok {
|
||||
t.Errorf("%s curio %q is not in magicItemRegistry", s.Key, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_OmenIsNonCombat — a season's themed omen carries an effect and
|
||||
// only touches the non-combat omen levers, so overriding the weekly rotation with
|
||||
// it can never move the combat golden or the balance corpus. §E4/§B3.
|
||||
func TestSeasonTable_OmenIsNonCombat(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
o := s.Omen
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("%s omen has no active effect", s.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("%s omen missing player-facing copy", s.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_VisitorRewardComplete — every season's road visitor has flavor,
|
||||
// a named keepsake, and a positive keepsake value, so applyAmbientEffect always
|
||||
// has a real reward to grant.
|
||||
func TestSeasonTable_VisitorRewardComplete(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
v := s.Visitor
|
||||
if len(v.FlavorPool) == 0 {
|
||||
t.Errorf("%s visitor has no flavor", s.Key)
|
||||
}
|
||||
if v.Keepsake == "" || v.KeepsakeValue <= 0 {
|
||||
t.Errorf("%s visitor keepsake incomplete: %q value %d", s.Key, v.Keepsake, v.KeepsakeValue)
|
||||
}
|
||||
if v.Weight <= 0 {
|
||||
t.Errorf("%s visitor has non-positive weight %d", s.Key, v.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveSeason_NeutralizedInSim — the balance sim must never observe a season
|
||||
// through any path; activeSeason honours simOmenDisabled exactly as activeOmen does.
|
||||
func TestActiveSeason_NeutralizedInSim(t *testing.T) {
|
||||
prev := simOmenDisabled
|
||||
simOmenDisabled = true
|
||||
defer func() { simOmenDisabled = prev }()
|
||||
if s, ok := activeSeason(); ok {
|
||||
t.Fatalf("activeSeason returned %q while simOmenDisabled", s.Key)
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// registeredSecretNodes collects every NodeKindSecret node across all
|
||||
// registered zone graphs, keyed by node ID.
|
||||
func registeredSecretNodes(t *testing.T) map[string]ZoneNode {
|
||||
t.Helper()
|
||||
out := map[string]ZoneNode{}
|
||||
for _, z := range allZones() {
|
||||
g, ok := loadZoneGraph(z.ID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for id, n := range g.Nodes {
|
||||
if n.Kind == NodeKindSecret {
|
||||
out[id] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSecretRoomDiscovery_EverySecretHasBespokeFlavor guards that no secret
|
||||
// node ships on the generic fallback — every one gets an authored line. Catches
|
||||
// a future secret room added without a discovery entry.
|
||||
func TestSecretRoomDiscovery_EverySecretHasBespokeFlavor(t *testing.T) {
|
||||
for id, n := range registeredSecretNodes(t) {
|
||||
if _, ok := secretRoomDiscovery[id]; !ok {
|
||||
t.Errorf("secret node %q (%q) has no bespoke discovery line", id, n.Label)
|
||||
}
|
||||
if line := secretRoomDiscoveryLine(n); strings.TrimSpace(line) == "" {
|
||||
t.Errorf("secret node %q produced an empty discovery line", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRoomDiscoveryLine_FallsBackOnUnknownNode(t *testing.T) {
|
||||
n := ZoneNode{NodeID: "made_up.node", Kind: NodeKindSecret, Label: "Nowhere Nook"}
|
||||
line := secretRoomDiscoveryLine(n)
|
||||
if !strings.Contains(line, "Nowhere Nook") {
|
||||
t.Errorf("fallback should name the node label, got: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretRoomKeys_UnlockRealDestinationEdges is the cross-zone-key
|
||||
// consistency check: every key's item Name must match a LockKey edge's key_id
|
||||
// (lower-cased) in its destination zone, and every source must be a real secret
|
||||
// room. A typo between the granted item name and the authored key_id would
|
||||
// silently make a vault permanently unreachable; this catches it.
|
||||
func TestSecretRoomKeys_UnlockRealDestinationEdges(t *testing.T) {
|
||||
secrets := registeredSecretNodes(t)
|
||||
for srcNode, key := range secretRoomKeys {
|
||||
if _, ok := secrets[srcNode]; !ok {
|
||||
t.Errorf("key source %q is not a registered secret room", srcNode)
|
||||
}
|
||||
g, ok := loadZoneGraph(key.unlocksIn)
|
||||
if !ok {
|
||||
t.Errorf("%s: destination zone %q has no graph", srcNode, key.unlocksIn)
|
||||
continue
|
||||
}
|
||||
wantKeyID := strings.ToLower(key.item.Name)
|
||||
found := false
|
||||
for _, outs := range g.Edges {
|
||||
for _, e := range outs {
|
||||
if e.Lock == LockKey && strings.ToLower(lockDataString(e.LockData, "key_id")) == wantKeyID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("key %q (from %s) has no LockKey edge with key_id=%q in zone %q — vault unreachable",
|
||||
key.item.Name, srcNode, wantKeyID, key.unlocksIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossZoneKey_UnlocksWithItemNotWithout exercises the runtime lock
|
||||
// evaluation end-to-end on a real authored edge.
|
||||
func TestCrossZoneKey_UnlocksWithItemNotWithout(t *testing.T) {
|
||||
g, ok := loadZoneGraph(ZoneManorBlackspire)
|
||||
if !ok {
|
||||
t.Fatal("manor graph missing")
|
||||
}
|
||||
var keyEdge ZoneEdge
|
||||
for _, e := range g.outgoingEdges("manor_blackspire.upper_hall") {
|
||||
if e.Lock == LockKey {
|
||||
keyEdge = e
|
||||
}
|
||||
}
|
||||
if keyEdge.To == "" {
|
||||
t.Fatal("no LockKey edge off manor upper_hall")
|
||||
}
|
||||
|
||||
base := edgeUnlockCtx{RunID: "r", FromNode: "manor_blackspire.upper_hall"}
|
||||
if ok, _ := evaluateEdgeLock(keyEdge, base); ok {
|
||||
t.Error("edge should be locked without the key in inventory")
|
||||
}
|
||||
|
||||
withKey := base
|
||||
withKey.InventoryNames = map[string]bool{"sunken sigil": true}
|
||||
if ok, reason := evaluateEdgeLock(keyEdge, withKey); !ok {
|
||||
t.Errorf("edge should unlock with the sigil in inventory, got locked: %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantSecretRoomKey_GrantsOnceIdempotent verifies a key-bearing secret
|
||||
// hands its key over exactly once.
|
||||
func TestGrantSecretRoomKey_GrantsOnceIdempotent(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@keyholder:test.invalid")
|
||||
|
||||
node := ZoneNode{NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret}
|
||||
line := p.grantSecretRoomKey(u, node)
|
||||
if !strings.Contains(line, "Sunken Sigil") {
|
||||
t.Fatalf("first grant should name the key, got: %q", line)
|
||||
}
|
||||
|
||||
items, err := loadAdvInventory(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load inventory: %v", err)
|
||||
}
|
||||
sigils := 0
|
||||
for _, it := range items {
|
||||
if strings.EqualFold(it.Name, "Sunken Sigil") {
|
||||
sigils++
|
||||
}
|
||||
}
|
||||
if sigils != 1 {
|
||||
t.Fatalf("want exactly 1 Sunken Sigil, got %d", sigils)
|
||||
}
|
||||
|
||||
// Second find on a later run must not stack a duplicate.
|
||||
if again := p.grantSecretRoomKey(u, node); again != "" {
|
||||
t.Errorf("re-finding the room should not re-grant the key, got: %q", again)
|
||||
}
|
||||
items, _ = loadAdvInventory(u)
|
||||
sigils = 0
|
||||
for _, it := range items {
|
||||
if strings.EqualFold(it.Name, "Sunken Sigil") {
|
||||
sigils++
|
||||
}
|
||||
}
|
||||
if sigils != 1 {
|
||||
t.Fatalf("still want exactly 1 Sunken Sigil after re-find, got %d", sigils)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantSecretRoomKey_NoKeyForPlainSecret confirms a secret room that carries
|
||||
// no cross-zone key stays silent.
|
||||
func TestGrantSecretRoomKey_NoKeyForPlainSecret(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@plain:test.invalid")
|
||||
node := ZoneNode{NodeID: "forest_shadows.sapling_shrine", Kind: NodeKindSecret}
|
||||
if line := p.grantSecretRoomKey(u, node); line != "" {
|
||||
t.Errorf("plain secret should grant no key, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSecretRoom_GrantsPageCacheAndKey checks the full treasure-cache
|
||||
// payout: a journal page, the consumable cache, and (for a key-bearing secret)
|
||||
// the cross-zone key — with no combat touching HP.
|
||||
func TestResolveSecretRoom_GrantsPageCacheAndKey(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@secret:test.invalid")
|
||||
|
||||
zone, ok := getZone(ZoneSunkenTemple)
|
||||
if !ok {
|
||||
t.Fatal("sunken temple zone missing")
|
||||
}
|
||||
node := ZoneNode{
|
||||
NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret,
|
||||
Label: "Coral Reliquary",
|
||||
Content: ZoneNodeContent{LootBias: 1.8},
|
||||
}
|
||||
run := &DungeonRun{UserID: string(u), ZoneID: ZoneSunkenTemple, CurrentNode: node.NodeID}
|
||||
|
||||
out := p.resolveSecretRoom(u, run, zone, node)
|
||||
if !strings.Contains(out, "Coral Reliquary") {
|
||||
t.Errorf("outcome should carry the discovery line:\n%s", out)
|
||||
}
|
||||
|
||||
// A journal page was granted (fresh player, nothing found yet).
|
||||
if mask, err := loadJournalPages(u); err != nil || journalPageCount(mask) != 1 {
|
||||
t.Fatalf("want exactly 1 journal page granted, got count=%d err=%v", journalPageCount(mask), err)
|
||||
}
|
||||
|
||||
// The guaranteed cache + the cross-zone key are in inventory.
|
||||
items, err := loadAdvInventory(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load inventory: %v", err)
|
||||
}
|
||||
var caches, keys int
|
||||
for _, it := range items {
|
||||
switch {
|
||||
case it.Type == "key":
|
||||
keys++
|
||||
case it.Type == "consumable":
|
||||
caches++
|
||||
}
|
||||
}
|
||||
if caches != secretRoomCacheCount {
|
||||
t.Errorf("want %d cache consumables, got %d", secretRoomCacheCount, caches)
|
||||
}
|
||||
if keys != 1 {
|
||||
t.Errorf("want the Sunken Sigil key granted, got %d keys", keys)
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The Shadow (N6/D3) — a per-player simulated rival adventurer. It "runs" the
|
||||
// same zone progression the player does, advanced once per UTC day by the
|
||||
// midnight ticker at ~1.3x the player's own long-run clear pace, so it stays
|
||||
// just ahead: a rival you can always see and always nearly catch.
|
||||
//
|
||||
// It is pure theatre. There is no Shadow combat, no punishment, no state the
|
||||
// player can lose. The only mechanics are race pressure (a morning-briefing
|
||||
// line telling you where it is relative to you) and two payoffs at each zone
|
||||
// clear: beat the Shadow to a zone and TwinBee crows a little bonus XP; let the
|
||||
// Shadow clear it first and it leaves a journal page waiting in the boss room
|
||||
// (the D1 campaign tie-in). All Shadow narration TwinBee speaks is first-person,
|
||||
// he/him, implicit-subject — the campaign voice rules (feedback_twinbee_voice,
|
||||
// feedback_twinbee_is_male). The Shadow itself is referred to as "the Shadow"
|
||||
// (or by its name); "he" in TwinBee's lines is TwinBee, never a third-person
|
||||
// "TwinBee".
|
||||
|
||||
const (
|
||||
// shadowPaceMultiplier keeps the Shadow just ahead: it clears at ~1.3x the
|
||||
// player's own long-run pace.
|
||||
shadowPaceMultiplier = 1.3
|
||||
// shadowMinDailyStep is the floor on a day's advance, so a brand-new or idle
|
||||
// player still watches the rival creep forward rather than sit frozen.
|
||||
shadowMinDailyStep = 0.2
|
||||
// shadowMaxDailyStep caps a day's advance at just under one zone, so even a
|
||||
// prolific player never sees the Shadow clear two zones overnight.
|
||||
shadowMaxDailyStep = 1.0
|
||||
// shadowMaxLead caps how far ahead the Shadow may run — it is a race, not a
|
||||
// runaway. It will never sit more than ~2.5 zones past the player's own count.
|
||||
shadowMaxLead = 2.5
|
||||
// shadowCrowXPPerTier scales the "you beat me here" bonus by zone tier, so a
|
||||
// T5 crow is worth more than a T1 one. Small on purpose (lift, not a spike).
|
||||
shadowCrowXPPerTier = 12
|
||||
)
|
||||
|
||||
// shadowState mirrors the adventure_shadow row.
|
||||
type shadowState struct {
|
||||
UserID id.UserID
|
||||
Name string
|
||||
Progress float64
|
||||
ZonesCleared int
|
||||
PendingMask int64
|
||||
CrowedMask int64
|
||||
DayCounter int
|
||||
LastAdvanced string
|
||||
}
|
||||
|
||||
// The Shadow walks zoneOrder — the design-doc order the zone registry is built
|
||||
// in — start to finish, tier 1 → 5. Bit i of a Shadow's masks is zoneOrder[i].
|
||||
|
||||
// shadowZoneIndex returns the progression index of a zone, or -1 if it is not
|
||||
// on the path (the arena, an unknown id).
|
||||
func shadowZoneIndex(z ZoneID) int {
|
||||
for i, id := range zoneOrder {
|
||||
if id == z {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func shadowSetBit(mask int64, idx int) int64 {
|
||||
if idx < 0 || idx > 62 {
|
||||
return mask
|
||||
}
|
||||
return mask | (int64(1) << idx)
|
||||
}
|
||||
|
||||
func shadowClearBit(mask int64, idx int) int64 {
|
||||
if idx < 0 || idx > 62 {
|
||||
return mask
|
||||
}
|
||||
return mask &^ (int64(1) << idx)
|
||||
}
|
||||
|
||||
func shadowBitSet(mask int64, idx int) bool {
|
||||
if idx < 0 || idx > 62 {
|
||||
return false
|
||||
}
|
||||
return mask&(int64(1)<<idx) != 0
|
||||
}
|
||||
|
||||
// loadShadow reads a player's Shadow, or (nil, nil) if it hasn't been born yet.
|
||||
func loadShadow(userID id.UserID) (*shadowState, error) {
|
||||
s := &shadowState{UserID: userID}
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced
|
||||
FROM adventure_shadow WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&s.Name, &s.Progress, &s.ZonesCleared, &s.PendingMask, &s.CrowedMask, &s.DayCounter, &s.LastAdvanced)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// upsertShadow persists the whole Shadow row. The ticker is the only writer, so
|
||||
// a plain last-write-wins upsert is safe — there is no concurrent mutator to
|
||||
// race (unlike the player_meta columns a character save also touches).
|
||||
func upsertShadow(s *shadowState) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO adventure_shadow
|
||||
(user_id, name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
progress = excluded.progress,
|
||||
zones_cleared = excluded.zones_cleared,
|
||||
pending_mask = excluded.pending_mask,
|
||||
crowed_mask = excluded.crowed_mask,
|
||||
day_counter = excluded.day_counter,
|
||||
last_advanced = excluded.last_advanced`,
|
||||
string(s.UserID), s.Name, s.Progress, s.ZonesCleared, s.PendingMask,
|
||||
s.CrowedMask, s.DayCounter, s.LastAdvanced,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// shadowNames is the pool the rival's proper name is drawn from — cold,
|
||||
// half-familiar names for a thing that shadows you. Picked deterministically
|
||||
// from the player's own name so a given player always faces the same Shadow.
|
||||
var shadowNames = []string{
|
||||
"Vael", "Corriss", "Mordane", "Ashen", "Locke", "Grimma", "Sable",
|
||||
"Thane", "Wren", "Vesper", "Cael", "Orrin", "Dain", "Wraithe",
|
||||
"Nyx", "Solenne",
|
||||
}
|
||||
|
||||
// shadowNameFor seeds the Shadow's name from the player's display name so it is
|
||||
// stable per player and "seeded from the player's" (D3 spec). Falls back to the
|
||||
// user id when the display name is empty.
|
||||
func shadowNameFor(displayName string, userID id.UserID) string {
|
||||
seed := strings.ToLower(strings.TrimSpace(displayName))
|
||||
if seed == "" {
|
||||
seed = string(userID)
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(seed))
|
||||
return shadowNames[int(h.Sum32())%len(shadowNames)]
|
||||
}
|
||||
|
||||
// newShadow mints a fresh Shadow at the start line.
|
||||
func newShadow(userID id.UserID, displayName string) *shadowState {
|
||||
return &shadowState{
|
||||
UserID: userID,
|
||||
Name: shadowNameFor(displayName, userID),
|
||||
}
|
||||
}
|
||||
|
||||
// advanceShadow moves a player's Shadow forward one UTC day. Idempotent per day
|
||||
// via last_advanced, so the once-a-day midnight guard plus this makes a double
|
||||
// tick a no-op. Mints the Shadow on first call. Errors are logged and swallowed
|
||||
// — a stalled Shadow is cosmetic, and must never break the midnight reset loop.
|
||||
func (p *AdventurePlugin) advanceShadow(char *AdventureCharacter) {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
s, err := loadShadow(char.UserID)
|
||||
if err != nil {
|
||||
slog.Warn("shadow: load", "user", char.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
if s == nil {
|
||||
s = newShadow(char.UserID, char.DisplayName)
|
||||
}
|
||||
if s.LastAdvanced == today {
|
||||
return // already advanced this UTC day
|
||||
}
|
||||
|
||||
cleared := clearedZoneIDs(db.Get(), char.UserID)
|
||||
playerCount := len(cleared)
|
||||
|
||||
// The player's long-run pace, in zones per day, drives the Shadow's speed.
|
||||
// Floor it so a new/idle player still gets a creeping rival; the 1.3x keeps
|
||||
// the Shadow just ahead; cap the daily step so it never leaps two zones.
|
||||
step := (float64(playerCount) / float64(shadowPlayerAgeDays(char))) * shadowPaceMultiplier
|
||||
if step < shadowMinDailyStep {
|
||||
step = shadowMinDailyStep
|
||||
}
|
||||
if step > shadowMaxDailyStep {
|
||||
step = shadowMaxDailyStep
|
||||
}
|
||||
|
||||
newProg := s.Progress + step
|
||||
// Keep it a race: never more than shadowMaxLead zones past the player's count.
|
||||
if leadCap := float64(playerCount) + shadowMaxLead; newProg > leadCap {
|
||||
newProg = leadCap
|
||||
}
|
||||
if maxProg := float64(len(zoneOrder)); newProg > maxProg {
|
||||
newProg = maxProg
|
||||
}
|
||||
if newProg < s.Progress {
|
||||
newProg = s.Progress // never regress (a lead cap that dropped below current)
|
||||
}
|
||||
|
||||
// Any progression zone the Shadow newly finishes, and the player hasn't,
|
||||
// leaves a page waiting. Compare per-zone (not by count): the Shadow walks
|
||||
// in order while the player may clear out of order, so "did the Shadow clear
|
||||
// zone i first" is a per-zone question.
|
||||
newCleared := int(newProg)
|
||||
if newCleared > len(zoneOrder) {
|
||||
newCleared = len(zoneOrder)
|
||||
}
|
||||
for idx := s.ZonesCleared; idx < newCleared; idx++ {
|
||||
if !cleared[zoneOrder[idx]] {
|
||||
s.PendingMask = shadowSetBit(s.PendingMask, idx)
|
||||
}
|
||||
}
|
||||
|
||||
s.Progress = newProg
|
||||
s.ZonesCleared = newCleared
|
||||
s.DayCounter++
|
||||
s.LastAdvanced = today
|
||||
if err := upsertShadow(s); err != nil {
|
||||
slog.Warn("shadow: advance upsert", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shadowPlayerAgeDays estimates how many days the player has been adventuring,
|
||||
// clamped to at least 1 so a same-day character never divides by zero. Used as
|
||||
// the denominator of the player's pace.
|
||||
func shadowPlayerAgeDays(char *AdventureCharacter) float64 {
|
||||
if char.CreatedAt.IsZero() {
|
||||
return 1
|
||||
}
|
||||
days := time.Since(char.CreatedAt).Hours() / 24
|
||||
if days < 1 {
|
||||
return 1
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// shadowOnPlayerZoneClear is the payoff, fired when the player clears a zone's
|
||||
// boss. Three outcomes: the Shadow left a page here (pending bit) → grant the
|
||||
// page and clear the bit; the player beat the Shadow to this zone → a crow plus
|
||||
// a little bonus XP; or the Shadow already passed through with nothing owed →
|
||||
// nothing. Returns the narration line to append to the clear message ("" for
|
||||
// the silent case, or when the Shadow hasn't been born yet).
|
||||
func (p *AdventurePlugin) shadowOnPlayerZoneClear(userID id.UserID, zoneID ZoneID) string {
|
||||
s, err := loadShadow(userID)
|
||||
if err != nil || s == nil {
|
||||
return ""
|
||||
}
|
||||
idx := shadowZoneIndex(zoneID)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if shadowBitSet(s.PendingMask, idx) {
|
||||
// The Shadow got here first and left a page. Grant it BEFORE retiring the
|
||||
// debt: grantJournalPage returns "" both when the campaign is already
|
||||
// complete and on a transient DB error, so clear the pending bit only once
|
||||
// the page has actually landed (or the ledger is genuinely full). A
|
||||
// transient failure then leaves the bit set and the next clear retries,
|
||||
// rather than swallowing a page the player earned.
|
||||
pageLine := p.grantJournalPage(userID, nil)
|
||||
mask, _ := loadJournalPages(userID)
|
||||
if pageLine == "" && !journalComplete(mask) {
|
||||
slog.Warn("shadow: waiting page grant failed; leaving debt for retry",
|
||||
"user", userID, "zone", zoneID)
|
||||
return ""
|
||||
}
|
||||
s.PendingMask = shadowClearBit(s.PendingMask, idx)
|
||||
if err := upsertShadow(s); err != nil {
|
||||
// The page is already granted; a failed bit-clear can at worst re-grant
|
||||
// a different unfound page on a later re-clear, which is benign (pages
|
||||
// are collectible and the campaign self-limits). Better than losing it.
|
||||
slog.Warn("shadow: clear pending bit", "user", userID, "zone", zoneID, "err", err)
|
||||
}
|
||||
if pageLine == "" {
|
||||
// Ledger already complete — nothing to leave, but the Shadow was first.
|
||||
return shadowBeatYouHereLine(s)
|
||||
}
|
||||
return shadowLeftPageLine(s) + "\n" + pageLine
|
||||
}
|
||||
|
||||
// No page owed. If the Shadow hasn't reached this zone yet, the player beat
|
||||
// it here — crow and hand over a small bonus, but only once per zone: the
|
||||
// crowed bit makes re-running a not-yet-reached zone unable to farm the XP.
|
||||
if int(s.Progress) <= idx && !shadowBitSet(s.CrowedMask, idx) {
|
||||
xp := shadowCrowXP(zoneID)
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
// Don't promise XP we didn't grant, and don't burn the crow — leave the
|
||||
// bit unset so the next clear can crow for real.
|
||||
slog.Warn("shadow: crow xp", "user", userID, "err", err)
|
||||
return ""
|
||||
}
|
||||
s.CrowedMask = shadowSetBit(s.CrowedMask, idx)
|
||||
if err := upsertShadow(s); err != nil {
|
||||
slog.Warn("shadow: set crowed bit", "user", userID, "zone", zoneID, "err", err)
|
||||
}
|
||||
return shadowCrowLine(s, xp)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shadowCrowXP is the bonus for beating the Shadow to a zone, scaled by tier.
|
||||
func shadowCrowXP(zoneID ZoneID) int {
|
||||
tier := 1
|
||||
if z, ok := getZone(zoneID); ok {
|
||||
tier = int(z.Tier)
|
||||
}
|
||||
if tier < 1 {
|
||||
tier = 1
|
||||
}
|
||||
return tier * shadowCrowXPPerTier
|
||||
}
|
||||
|
||||
// shadowBriefingLine is the morning-briefing race-pressure beat: where the
|
||||
// Shadow stands relative to the player. Deterministic by day so a re-rendered
|
||||
// briefing reads the same, and silent until the Shadow has been born and has
|
||||
// something to say. TwinBee's voice.
|
||||
func (p *AdventurePlugin) shadowBriefingLine(e *Expedition) string {
|
||||
userID := id.UserID(e.UserID)
|
||||
s, err := loadShadow(userID)
|
||||
if err != nil || s == nil {
|
||||
return ""
|
||||
}
|
||||
playerCount := len(clearedZoneIDs(db.Get(), userID))
|
||||
lead := s.ZonesCleared - playerCount
|
||||
return shadowRacePressureLine(s, e.CurrentDay, lead)
|
||||
}
|
||||
|
||||
// ── Flavour ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// TwinBee's voice: first-person, implicit subject, he/him, one line. "The
|
||||
// Shadow"/"{name}" is the rival; "I"/"we" is TwinBee and the player. Never the
|
||||
// third-person "TwinBee" (guarded by test, same as the journal reactions).
|
||||
|
||||
// shadowAheadLines — TwinBee, the Shadow is ahead of us. Race pressure.
|
||||
var shadowAheadLines = []string{
|
||||
"Passed the Shadow's camp at dawn — cold ashes, a day old. %s is ahead of us again.",
|
||||
"Found %s's mark cut into a doorframe up ahead. Still warm. We're not gaining.",
|
||||
"%s came through here already. I can smell the pitch of a torch not long snuffed.",
|
||||
"The Shadow's been and gone. %s leaves the doors open behind, like he wants us to know.",
|
||||
}
|
||||
|
||||
// shadowBehindLines — TwinBee, we're ahead of the Shadow. Earned swagger.
|
||||
var shadowBehindLines = []string{
|
||||
"No sign of %s ahead — I think we're out in front for once. Let's keep it that way.",
|
||||
"We've outpaced the Shadow. %s is somewhere behind us, eating our dust. Good.",
|
||||
"Quiet up ahead. %s hasn't reached this deep yet. First ones through.",
|
||||
}
|
||||
|
||||
// shadowNeckLines — TwinBee, we're level with the Shadow. Tension.
|
||||
var shadowNeckLines = []string{
|
||||
"%s is right on our heels — or we're on his. Hard to say who's chasing who now.",
|
||||
"Neck and neck with the Shadow. I keep catching %s at the edge of the torchlight.",
|
||||
"%s is close. Same rooms, hours apart. Whoever clears the next zone first wins the day.",
|
||||
}
|
||||
|
||||
// shadowRacePressureLine picks a race-pressure line by the day and the Shadow's
|
||||
// lead, deterministically. lead > 0: Shadow ahead; lead < 0: player ahead; 0:
|
||||
// level.
|
||||
func shadowRacePressureLine(s *shadowState, day, lead int) string {
|
||||
var pool []string
|
||||
switch {
|
||||
case lead > 0:
|
||||
pool = shadowAheadLines
|
||||
case lead < 0:
|
||||
pool = shadowBehindLines
|
||||
default:
|
||||
pool = shadowNeckLines
|
||||
}
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
idx := (day + s.DayCounter) % len(pool)
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
return "👤 " + fmt.Sprintf(pool[idx], s.Name)
|
||||
}
|
||||
|
||||
// shadowLeftPageLine prefaces the waiting journal page: the Shadow cleared this
|
||||
// zone first and left a page behind for us. TwinBee's voice.
|
||||
func shadowLeftPageLine(s *shadowState) string {
|
||||
return fmt.Sprintf("👤 %s beat us to this one — but left a torn page pinned in the boss's lair, like a note for whoever came next.", s.Name)
|
||||
}
|
||||
|
||||
// shadowBeatYouHereLine is the page-less acknowledgement (campaign already
|
||||
// complete): the Shadow was first, but there's nothing left to find.
|
||||
func shadowBeatYouHereLine(s *shadowState) string {
|
||||
return fmt.Sprintf("👤 %s was here before us. Boot-prints in the dust, and nothing left to take. We know how this ends now anyway.", s.Name)
|
||||
}
|
||||
|
||||
// shadowCrowLine is TwinBee crowing that we beat the Shadow to a zone, with the
|
||||
// bonus XP.
|
||||
func shadowCrowLine(s *shadowState, xp int) string {
|
||||
return fmt.Sprintf("👤 First ones through — %s is still somewhere behind us. I'd crow if it wouldn't give us away. (**+%d XP**, for beating the Shadow here.)", s.Name, xp)
|
||||
}
|
||||
|
||||
// handleShadowCmd renders the player's standing against the Shadow. A quiet
|
||||
// status view — the feature lives mostly in the briefing and at zone clears.
|
||||
func (p *AdventurePlugin) handleShadowCmd(ctx MessageContext) error {
|
||||
s, err := loadShadow(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't read the trail just now. Try again in a moment.")
|
||||
}
|
||||
if s == nil {
|
||||
return p.SendDM(ctx.Sender, "👤 No one's shadowing you yet. Clear a zone or two and someone will take an interest.")
|
||||
}
|
||||
playerCount := len(clearedZoneIDs(db.Get(), ctx.Sender))
|
||||
total := len(zoneOrder)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("👤 **The Shadow — %s**\n\n", s.Name))
|
||||
b.WriteString(fmt.Sprintf("🏴 **%s's trail:** %d / %d zones cleared\n", s.Name, s.ZonesCleared, total))
|
||||
b.WriteString(fmt.Sprintf("🚩 **Your trail:** %d / %d zones cleared\n", playerCount, total))
|
||||
switch lead := s.ZonesCleared - playerCount; {
|
||||
case lead > 0:
|
||||
b.WriteString(fmt.Sprintf("\n_%s is **%d** ahead. Pages wait where he cleared first — take them back by clearing those zones yourself._", s.Name, lead))
|
||||
case lead < 0:
|
||||
b.WriteString(fmt.Sprintf("\n_You're **%d** ahead. Keep it that way._", -lead))
|
||||
default:
|
||||
b.WriteString("\n_Dead level. The next zone decides who's chasing whom._")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newShadowTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// freshChar builds a same-day character so shadowPlayerAgeDays reads 1 day.
|
||||
func freshChar(user id.UserID) *AdventureCharacter {
|
||||
return &AdventureCharacter{
|
||||
UserID: user,
|
||||
DisplayName: "Tester",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowName_DeterministicAndInPool(t *testing.T) {
|
||||
user := id.UserID("@name:test.invalid")
|
||||
first := shadowNameFor("Aria", user)
|
||||
if again := shadowNameFor("Aria", user); again != first {
|
||||
t.Errorf("name not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
inPool := false
|
||||
for _, n := range shadowNames {
|
||||
if n == first {
|
||||
inPool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inPool {
|
||||
t.Errorf("generated name %q not in shadowNames pool", first)
|
||||
}
|
||||
// Empty display name falls back to the user id and still yields a pool name.
|
||||
if got := shadowNameFor("", user); got == "" {
|
||||
t.Error("empty display name produced an empty shadow name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadowFlavor_NoThirdPersonTwinBee: every Shadow line TwinBee speaks obeys
|
||||
// the campaign voice rule — he never refers to himself in the third person
|
||||
// (feedback_twinbee_voice). Same guard the journal reactions carry.
|
||||
func TestShadowFlavor_NoThirdPersonTwinBee(t *testing.T) {
|
||||
pools := [][]string{shadowAheadLines, shadowBehindLines, shadowNeckLines}
|
||||
for _, pool := range pools {
|
||||
for _, line := range pool {
|
||||
if strings.Contains(line, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee in Shadow pool line: %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "%s") {
|
||||
t.Errorf("race-pressure line missing the name slot: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
s := &shadowState{Name: "Vael"}
|
||||
rendered := []string{
|
||||
shadowLeftPageLine(s),
|
||||
shadowBeatYouHereLine(s),
|
||||
shadowCrowLine(s, 24),
|
||||
shadowRacePressureLine(s, 3, 1),
|
||||
shadowRacePressureLine(s, 3, -1),
|
||||
shadowRacePressureLine(s, 3, 0),
|
||||
}
|
||||
for _, r := range rendered {
|
||||
if strings.Contains(r, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee in rendered Shadow line: %q", r)
|
||||
}
|
||||
if !strings.Contains(r, "Vael") {
|
||||
t.Errorf("rendered line dropped the Shadow's name: %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowRacePressure_Deterministic(t *testing.T) {
|
||||
s := &shadowState{Name: "Vael", DayCounter: 2}
|
||||
first := shadowRacePressureLine(s, 4, 1)
|
||||
if again := shadowRacePressureLine(s, 4, 1); again != first {
|
||||
t.Errorf("race pressure not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
// Direction picks a different pool: ahead vs behind must differ in wording.
|
||||
ahead := shadowRacePressureLine(s, 4, 2)
|
||||
behind := shadowRacePressureLine(s, 4, -2)
|
||||
if ahead == behind {
|
||||
t.Errorf("ahead and behind produced the same line: %q", ahead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_CreepsAndIsIdempotent(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@creep:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, err := loadShadow(user)
|
||||
if err != nil || s == nil {
|
||||
t.Fatalf("shadow not born: %v", err)
|
||||
}
|
||||
if s.Progress <= 0 {
|
||||
t.Errorf("a born shadow should have crept forward, got progress %v", s.Progress)
|
||||
}
|
||||
if s.Name == "" {
|
||||
t.Error("born shadow has no name")
|
||||
}
|
||||
firstProg := s.Progress
|
||||
|
||||
// Second advance the same UTC day is a no-op.
|
||||
p.advanceShadow(char)
|
||||
s2, _ := loadShadow(user)
|
||||
if s2.Progress != firstProg {
|
||||
t.Errorf("same-day re-advance moved the shadow: %v -> %v", firstProg, s2.Progress)
|
||||
}
|
||||
|
||||
// Roll the clock back a day and it advances again.
|
||||
s2.LastAdvanced = time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
if err := upsertShadow(s2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.advanceShadow(char)
|
||||
s3, _ := loadShadow(user)
|
||||
if s3.Progress <= firstProg {
|
||||
t.Errorf("a new day should have advanced the shadow: %v -> %v", firstProg, s3.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_PendingBitForUnclearedZone(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@pending:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
// Seed the shadow one step short of clearing zone 0, dated yesterday so the
|
||||
// next advance runs. The player has cleared nothing.
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = 0.95
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.ZonesCleared < 1 {
|
||||
t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d prog=%v", s.ZonesCleared, s.Progress)
|
||||
}
|
||||
if !shadowBitSet(s.PendingMask, 0) {
|
||||
t.Errorf("shadow cleared zone 0 before the player but left no pending page (mask=%b)", s.PendingMask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_NoPendingWhenPlayerClearedFirst(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@nopending:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
// Player already cleared zone 0.
|
||||
insertClearedExpedition(t, user, zoneOrder[0], ExpeditionStatusComplete, 1, "{}")
|
||||
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = 0.95
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.ZonesCleared < 1 {
|
||||
t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d", s.ZonesCleared)
|
||||
}
|
||||
if shadowBitSet(s.PendingMask, 0) {
|
||||
t.Errorf("player cleared zone 0 first — no page should wait (mask=%b)", s.PendingMask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_LeadCapKeepsItARace(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@leadcap:test.invalid")
|
||||
char := freshChar(user) // player has cleared nothing → count 0
|
||||
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = shadowMaxLead // already at the cap for a 0-clear player
|
||||
seed.ZonesCleared = 2
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.Progress > shadowMaxLead+0.0001 {
|
||||
t.Errorf("shadow ran past the lead cap: progress %v > %v", s.Progress, shadowMaxLead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_PendingGrantsWaitingPage(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@page:test.invalid")
|
||||
|
||||
idx := 2
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 5
|
||||
seed.ZonesCleared = 5
|
||||
seed.PendingMask = shadowSetBit(0, idx)
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if strings.TrimSpace(line) == "" {
|
||||
t.Fatal("a waiting page should have produced a clear-message line")
|
||||
}
|
||||
if !strings.Contains(line, "page") {
|
||||
t.Errorf("expected a journal-page grant in the line, got: %q", line)
|
||||
}
|
||||
// Bit cleared so a re-clear can't re-award.
|
||||
s, _ := loadShadow(user)
|
||||
if shadowBitSet(s.PendingMask, idx) {
|
||||
t.Error("pending bit not cleared after the page was granted")
|
||||
}
|
||||
// The page actually landed.
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 {
|
||||
t.Errorf("expected exactly one journal page granted, got %d", journalPageCount(mask))
|
||||
}
|
||||
|
||||
// A second clear of the same zone grants nothing more.
|
||||
again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if again != "" {
|
||||
t.Errorf("re-clearing a paid-out zone should be silent, got: %q", again)
|
||||
}
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 {
|
||||
t.Errorf("re-clear granted a second page (count %d)", journalPageCount(mask))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_PlayerFirstCrows(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@crow:test.invalid")
|
||||
|
||||
idx := 3
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 0 // shadow hasn't reached this zone
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if !strings.Contains(line, "XP") {
|
||||
t.Errorf("beating the shadow should crow a bonus, got: %q", line)
|
||||
}
|
||||
// No page granted on a crow.
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 0 {
|
||||
t.Errorf("a crow should not grant a page (count %d)", journalPageCount(mask))
|
||||
}
|
||||
// The crowed bit is set so a re-run of the same not-yet-reached zone can't
|
||||
// farm the XP again — second clear is silent.
|
||||
if s, _ := loadShadow(user); !shadowBitSet(s.CrowedMask, idx) {
|
||||
t.Error("crowed bit not set after the first crow")
|
||||
}
|
||||
if again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); again != "" {
|
||||
t.Errorf("re-clearing an already-crowed zone should be silent, got: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_ShadowPassedNoDebt(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@passed:test.invalid")
|
||||
|
||||
idx := 1
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 5 // well past this zone
|
||||
seed.ZonesCleared = 5
|
||||
seed.PendingMask = 0 // nothing owed here
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); line != "" {
|
||||
t.Errorf("a zone the shadow passed with nothing owed should be silent, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_SilentBeforeBirth(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@unborn:test.invalid")
|
||||
if line := p.shadowOnPlayerZoneClear(user, zoneOrder[0]); line != "" {
|
||||
t.Errorf("no shadow yet should be silent, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowBriefingLine_SilentUntilBorn(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@brief:test.invalid")
|
||||
e := &Expedition{UserID: string(user), ZoneID: zoneOrder[0], CurrentDay: 2}
|
||||
|
||||
if line := p.shadowBriefingLine(e); line != "" {
|
||||
t.Errorf("no shadow should mean no briefing line, got: %q", line)
|
||||
}
|
||||
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.ZonesCleared = 2
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if line := p.shadowBriefingLine(e); strings.TrimSpace(line) == "" {
|
||||
t.Error("a born shadow should produce a briefing line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleShadowCmd_DMsStatus(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
sink := installSink(p)
|
||||
user := id.UserID("@cmd:test.invalid")
|
||||
|
||||
// Before birth: a gentle "no one's shadowing you yet".
|
||||
if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dms := sink.dmsTo(user); len(dms) != 1 || !strings.Contains(dms[0], "shadowing you yet") {
|
||||
t.Errorf("expected an unborn-shadow DM, got: %v", sink.dmsTo(user))
|
||||
}
|
||||
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.ZonesCleared = 4
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dms := sink.dmsTo(user)
|
||||
if len(dms) != 2 || !strings.Contains(dms[1], seed.Name) {
|
||||
t.Errorf("expected a status DM naming the shadow, got: %v", dms)
|
||||
}
|
||||
}
|
||||
@@ -1146,38 +1146,13 @@ func dailyCuriosStock() []MagicItem {
|
||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
|
||||
target := curiosStockSize
|
||||
if target > len(ids) {
|
||||
target = len(ids)
|
||||
n := curiosStockSize
|
||||
if n > len(ids) {
|
||||
n = len(ids)
|
||||
}
|
||||
stock := make([]MagicItem, 0, target)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// N7/E4 — a live season features its curated items at the front of the shelf,
|
||||
// displacing the day's rotation. They are existing registry items, so no
|
||||
// net-new power enters the economy for the week; only which items rotate in.
|
||||
if s, ok := activeSeason(); ok {
|
||||
for _, id := range s.CurioIDs {
|
||||
if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
|
||||
stock = append(stock, mi)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if len(stock) > target {
|
||||
target = len(stock)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the remaining slots from the day's deterministic rotation.
|
||||
for _, id := range ids {
|
||||
if len(stock) >= target {
|
||||
break
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
stock := make([]MagicItem, 0, n)
|
||||
for _, id := range ids[:n] {
|
||||
stock = append(stock, magicItemRegistry[id])
|
||||
seen[id] = true
|
||||
}
|
||||
// Stable display order: rarity ascending, then name.
|
||||
sort.Slice(stock, func(i, j int) bool {
|
||||
@@ -1192,11 +1167,7 @@ func dailyCuriosStock() []MagicItem {
|
||||
|
||||
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
|
||||
} else {
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
}
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
factor := p.shopSessionPriceFactor(userID)
|
||||
|
||||
@@ -281,17 +281,17 @@ func TestLuigiCategoryIntros_Coverage(t *testing.T) {
|
||||
|
||||
func TestLuigiFlavorPools_NonEmpty(t *testing.T) {
|
||||
pools := map[string][]string{
|
||||
"luigiGreetings": luigiGreetings,
|
||||
"luigiPurchaseConfirm": luigiPurchaseConfirm,
|
||||
"luigiTier5Confirm": luigiTier5Confirm,
|
||||
"luigiComboConfirm": luigiComboConfirm,
|
||||
"luigiGreetings": luigiGreetings,
|
||||
"luigiPurchaseConfirm": luigiPurchaseConfirm,
|
||||
"luigiTier5Confirm": luigiTier5Confirm,
|
||||
"luigiComboConfirm": luigiComboConfirm,
|
||||
"luigiInsufficientFunds": luigiInsufficientFunds,
|
||||
"luigiBrowseTimeout": luigiBrowseTimeout,
|
||||
"luigiMaxedOut": luigiMaxedOut,
|
||||
"luigiMasterworkAck": luigiMasterworkAck,
|
||||
"luigiShowAllComment": luigiShowAllComment,
|
||||
"luigiCommentary": luigiCommentary,
|
||||
"luigiCancellation": luigiCancellation,
|
||||
"luigiBrowseTimeout": luigiBrowseTimeout,
|
||||
"luigiMaxedOut": luigiMaxedOut,
|
||||
"luigiMasterworkAck": luigiMasterworkAck,
|
||||
"luigiShowAllComment": luigiShowAllComment,
|
||||
"luigiCommentary": luigiCommentary,
|
||||
"luigiCancellation": luigiCancellation,
|
||||
}
|
||||
for name, pool := range pools {
|
||||
if len(pool) == 0 {
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,13 +231,13 @@ func TestCalculateAdvProbabilities_BetterGearReducesDeath(t *testing.T) {
|
||||
|
||||
func TestAdvEffectiveSkill(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
CombatLevel: 10,
|
||||
MiningSkill: 15,
|
||||
CombatLevel: 10,
|
||||
MiningSkill: 15,
|
||||
ForagingSkill: 20,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{
|
||||
CombatBonus: 5,
|
||||
MiningBonus: 3,
|
||||
CombatBonus: 5,
|
||||
MiningBonus: 3,
|
||||
ForagingBonus: 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// E3 — Surfacing the buried social data. Three read-only registries that
|
||||
// render data the game already stores: !town (civic pride + housing + pets),
|
||||
// !graveyard (recent deaths), and !rivals board (room-wide duel standings).
|
||||
//
|
||||
// Leak-check (gogobee_engagement_plan.md §E3): the civic-pride board ranks
|
||||
// tax_ledger.total_paid, which is dominated by gambling/shop/arena rake and
|
||||
// carries ZERO Misty/Arina donation signal — those debits never touch
|
||||
// tax_ledger (adventure_npcs.go routes them through euro.Debit with their own
|
||||
// reason strings, counters live in player_meta.misty_*/arina_* columns). So no
|
||||
// board here can let a player correlate donations with the hidden NPC buffs.
|
||||
|
||||
const (
|
||||
// townCivicBoardLimit caps the civic-pride board.
|
||||
townCivicBoardLimit = 10
|
||||
// townListCap caps the housing/pet showcase lists.
|
||||
townListCap = 15
|
||||
// graveyardLimit caps the graveyard.
|
||||
graveyardLimit = 12
|
||||
// rivalsBoardLimit caps the room-wide rivalry standings.
|
||||
rivalsBoardLimit = 10
|
||||
)
|
||||
|
||||
// ── Civic pride ──────────────────────────────────────────────────────────────
|
||||
|
||||
type civicEntry struct {
|
||||
Name string
|
||||
TotalPaid int64
|
||||
}
|
||||
|
||||
// loadCivicPrideBoard returns the top contributors to the community pot by
|
||||
// lifetime tax paid. Safe to rank — see the leak-check note above.
|
||||
func loadCivicPrideBoard(limit int) ([]civicEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(c.display_name, ''), t.user_id), t.total_paid
|
||||
FROM tax_ledger t
|
||||
LEFT JOIN player_meta c ON c.user_id = t.user_id
|
||||
WHERE t.total_paid > 0
|
||||
ORDER BY t.total_paid DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []civicEntry
|
||||
for rows.Next() {
|
||||
var e civicEntry
|
||||
if err := rows.Scan(&e.Name, &e.TotalPaid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Housing street ───────────────────────────────────────────────────────────
|
||||
|
||||
type housingEntry struct {
|
||||
Name string
|
||||
Tier int
|
||||
}
|
||||
|
||||
// loadHousingStreet returns everyone who owns a house, best homes first.
|
||||
func loadHousingStreet(limit int) ([]housingEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), user_id), house_tier
|
||||
FROM player_meta
|
||||
WHERE house_tier > 0
|
||||
ORDER BY house_tier DESC, display_name
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []housingEntry
|
||||
for rows.Next() {
|
||||
var e housingEntry
|
||||
if err := rows.Scan(&e.Name, &e.Tier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Pet showcase ─────────────────────────────────────────────────────────────
|
||||
|
||||
type petShowcaseEntry struct {
|
||||
Name string
|
||||
Type string
|
||||
Level int
|
||||
ArmorName string
|
||||
}
|
||||
|
||||
// loadPetShowcase returns every active pet — both slots — across all players,
|
||||
// highest level first. The arrived/chased flags live in the *_flags_json
|
||||
// columns, so they're decoded in Go rather than in SQL.
|
||||
func loadPetShowcase(limit int) ([]petShowcaseEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
|
||||
pet_name, pet_type, pet_level, pet_armor_tier, pet_flags_json,
|
||||
pet2_name, pet2_type, pet2_level, pet2_armor_tier, pet2_flags_json
|
||||
FROM player_meta
|
||||
WHERE pet_type != '' OR pet2_type != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []petShowcaseEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
owner string
|
||||
petName, petType, flagsRaw string
|
||||
level, armorTier int
|
||||
pet2Name, pet2Type, flags2Raw string
|
||||
pet2Level, pet2Armor int
|
||||
)
|
||||
if err := rows.Scan(&owner, &petName, &petType, &level, &armorTier, &flagsRaw,
|
||||
&pet2Name, &pet2Type, &pet2Level, &pet2Armor, &flags2Raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e, ok := showcaseEntryFor(owner, petName, petType, level, armorTier, flagsRaw); ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
if e, ok := showcaseEntryFor(owner, pet2Name, pet2Type, pet2Level, pet2Armor, flags2Raw); ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Level != out[j].Level {
|
||||
return out[i].Level > out[j].Level
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// showcaseEntryFor builds a showcase entry for one pet slot, returning ok=false
|
||||
// if the slot is empty or its pet was chased away.
|
||||
func showcaseEntryFor(owner, petName, petType string, level, armorTier int, flagsRaw string) (petShowcaseEntry, bool) {
|
||||
if petType == "" {
|
||||
return petShowcaseEntry{}, false
|
||||
}
|
||||
var flags petFlagsJSON
|
||||
if flagsRaw != "" {
|
||||
_ = json.Unmarshal([]byte(flagsRaw), &flags)
|
||||
}
|
||||
if !flags.Arrived || flags.ChasedAway {
|
||||
return petShowcaseEntry{}, false
|
||||
}
|
||||
return petShowcaseEntry{
|
||||
Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")",
|
||||
Type: petType,
|
||||
Level: level,
|
||||
ArmorName: petArmorName(petType, armorTier),
|
||||
}, true
|
||||
}
|
||||
|
||||
// petArmorName maps a pet's armor tier to its barding name, or "" for none.
|
||||
func petArmorName(petType string, tier int) string {
|
||||
if tier <= 0 {
|
||||
return ""
|
||||
}
|
||||
for _, a := range petArmorDefs(petType) {
|
||||
if a.Tier == tier {
|
||||
return a.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Graveyard ────────────────────────────────────────────────────────────────
|
||||
|
||||
type graveEntry struct {
|
||||
Name string
|
||||
Source string // "adventure" | "arena" | ""
|
||||
Location string
|
||||
LastDeathDate string // "2006-01-02"
|
||||
StillDown bool // alive == 0 (inside the 6h respawn window)
|
||||
}
|
||||
|
||||
// loadGraveyard returns the most recent deaths across all players. Deaths are
|
||||
// day-granularity (last_death_date), so ordering falls back to dead_until for
|
||||
// the still-in-the-ground; a revived player keeps their last_death_date but
|
||||
// clears dead_until.
|
||||
func loadGraveyard(limit int) ([]graveEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
|
||||
alive, death_source, death_location, last_death_date, dead_until
|
||||
FROM player_meta
|
||||
WHERE last_death_date != ''
|
||||
ORDER BY last_death_date DESC, dead_until DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []graveEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
e graveEntry
|
||||
aliveInt int
|
||||
deadUntil sql.NullTime
|
||||
)
|
||||
if err := rows.Scan(&e.Name, &aliveInt, &e.Source, &e.Location, &e.LastDeathDate, &deadUntil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.StillDown = aliveInt == 0
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Rivals board ─────────────────────────────────────────────────────────────
|
||||
|
||||
type rivalBoardEntry struct {
|
||||
Name string
|
||||
Wins int
|
||||
Losses int
|
||||
LastDuelAt *time.Time
|
||||
}
|
||||
|
||||
// loadRivalsBoard aggregates adventure_rival_records into room-wide standings.
|
||||
// The table is a directed per-pair ledger written from both duellists' sides,
|
||||
// so summing a user_id's rows gives that player's total record.
|
||||
func loadRivalsBoard(limit int) ([]rivalBoardEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(c.display_name, ''), r.user_id),
|
||||
SUM(r.wins), SUM(r.losses), MAX(r.last_duel_at)
|
||||
FROM adventure_rival_records r
|
||||
LEFT JOIN player_meta c ON c.user_id = r.user_id
|
||||
GROUP BY r.user_id
|
||||
ORDER BY SUM(r.wins) DESC, SUM(r.losses) ASC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []rivalBoardEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
e rivalBoardEntry
|
||||
lastDuel sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&e.Name, &e.Wins, &e.Losses, &lastDuel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// MAX() over a DATETIME column loses SQLite's type affinity and comes
|
||||
// back as text, so parse it by hand rather than scanning a NullTime.
|
||||
if lastDuel.Valid {
|
||||
if t, ok := parseSQLiteTime(lastDuel.String); ok {
|
||||
e.LastDuelAt = &t
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Rendering (client-free, unit-testable) ───────────────────────────────────
|
||||
|
||||
func renderTownRegistry(civic []civicEntry, houses []housingEntry, pets []petShowcaseEntry) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🏛️ **The Town Registry**\n\n")
|
||||
|
||||
sb.WriteString("**Civic Pride** — the guild's most generous coffers-fillers\n")
|
||||
if len(civic) == 0 {
|
||||
sb.WriteString(" _The collection plate is empty. Tragic._\n")
|
||||
} else {
|
||||
for i, e := range civic {
|
||||
sb.WriteString(fmt.Sprintf(" %2d. %-18s %s\n", i+1, truncName(e.Name, 18), fmtEuro(e.TotalPaid)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("**Housing Street** — who's put down roots\n")
|
||||
if len(houses) == 0 {
|
||||
sb.WriteString(" _Not a single deed on file. A town of drifters._\n")
|
||||
} else {
|
||||
for _, e := range houses {
|
||||
tierName := "a house"
|
||||
if def := houseTierByTier(e.Tier); def != nil {
|
||||
tierName = def.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %-18s %s\n", truncName(e.Name, 18), tierName))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("**Pet Showcase** — the good companions\n")
|
||||
if len(pets) == 0 {
|
||||
sb.WriteString(" _No pets in town. Somebody adopt something._\n")
|
||||
} else {
|
||||
for _, e := range pets {
|
||||
line := fmt.Sprintf(" %-30s L%d %s", truncName(e.Name, 30), e.Level, e.Type)
|
||||
if e.ArmorName != "" {
|
||||
line += " · " + e.ArmorName
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
func renderGraveyard(deaths []graveEntry, now time.Time) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⚰️ **St. Guildmore's Memorial Garden**\n")
|
||||
sb.WriteString("_The groundskeeper tips his hat. \"They gave their all. Well — they gave enough.\"_\n\n")
|
||||
|
||||
if len(deaths) == 0 {
|
||||
sb.WriteString("_Nobody's died lately. The garden's quiet. Enjoy it while it lasts._")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for _, e := range deaths {
|
||||
place := e.Location
|
||||
if place == "" {
|
||||
place = "parts unknown"
|
||||
}
|
||||
verb := "fell"
|
||||
if e.Source == "arena" {
|
||||
verb = "fell in the arena"
|
||||
}
|
||||
when := humanizeDeathDate(e.LastDeathDate, now)
|
||||
line := fmt.Sprintf(" ⚰️ **%s** — %s at %s (%s)", truncName(e.Name, 24), verb, place, when)
|
||||
if e.StillDown {
|
||||
line += " · _still resting_"
|
||||
} else {
|
||||
line += " · _back on their feet_"
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
func renderRivalsBoard(board []rivalBoardEntry, now time.Time) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⚔️ **Rivalry Standings** — duels across the guild\n\n")
|
||||
|
||||
if len(board) == 0 {
|
||||
sb.WriteString("_No duels on record. A suspiciously peaceful town._")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for i, e := range board {
|
||||
last := "—"
|
||||
if e.LastDuelAt != nil {
|
||||
last = humanizeDaysAgo(*e.LastDuelAt, now)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %2d. %-18s %dW - %dL last duel: %s\n",
|
||||
i+1, truncName(e.Name, 18), e.Wins, e.Losses, last))
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// ── Small render helpers ─────────────────────────────────────────────────────
|
||||
|
||||
func truncName(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 1 {
|
||||
return string(r[:max])
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func firstNonEmpty(s, fallback string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseSQLiteTime parses a SQLite CURRENT_TIMESTAMP string ("2006-01-02
|
||||
// 15:04:05", UTC), tolerating an RFC3339 variant. Aggregates like MAX() strip
|
||||
// the column's type affinity so the driver hands back text.
|
||||
func parseSQLiteTime(s string) (time.Time, bool) {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// humanizeDaysAgo renders a duel/event time as "today" / "1 day ago" / "N days
|
||||
// ago", matching the inline style in handleRivalsCmd.
|
||||
func humanizeDaysAgo(t, now time.Time) string {
|
||||
days := int(now.Sub(t).Hours() / 24)
|
||||
switch {
|
||||
case days <= 0:
|
||||
return "today"
|
||||
case days == 1:
|
||||
return "1 day ago"
|
||||
default:
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
|
||||
// humanizeDeathDate renders a "2006-01-02" death date relative to now. Falls
|
||||
// back to the raw string if it doesn't parse.
|
||||
func humanizeDeathDate(dateStr string, now time.Time) string {
|
||||
t, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
if dateStr == "" {
|
||||
return "some time ago"
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
days := int(now.UTC().Truncate(24*time.Hour).Sub(t).Hours() / 24)
|
||||
switch {
|
||||
case days <= 0:
|
||||
return "today"
|
||||
case days == 1:
|
||||
return "yesterday"
|
||||
default:
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleTownCmd(ctx MessageContext) error {
|
||||
civic, err := loadCivicPrideBoard(townCivicBoardLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
houses, err := loadHousingStreet(townListCap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pets, err := loadPetShowcase(townListCap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderTownRegistry(civic, houses, pets))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleGraveyardCmd(ctx MessageContext) error {
|
||||
deaths, err := loadGraveyard(graveyardLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderGraveyard(deaths, time.Now().UTC()))
|
||||
}
|
||||
|
||||
// handleRivalsTopCmd routes the top-level !rivals command: "!rivals board" is
|
||||
// the room-wide standings; anything else defers to the per-user record view.
|
||||
func (p *AdventurePlugin) handleRivalsTopCmd(ctx MessageContext, args string) error {
|
||||
if strings.EqualFold(strings.TrimSpace(args), "board") {
|
||||
board, err := loadRivalsBoard(rivalsBoardLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderRivalsBoard(board, time.Now().UTC()))
|
||||
}
|
||||
return p.handleRivalsCmd(ctx)
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func townTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// TestTownLoaders_ExerciseRealSchema seeds each source table and runs every
|
||||
// loader against a real DB, catching column typos / GROUP BY errors that the
|
||||
// pure render tests can't.
|
||||
func TestTownLoaders_ExerciseRealSchema(t *testing.T) {
|
||||
townTestDB(t)
|
||||
|
||||
alice := id.UserID("@alice:test.invalid")
|
||||
bob := id.UserID("@bob:test.invalid")
|
||||
|
||||
if err := upsertPlayerMetaDisplayName(alice, "Alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertPlayerMetaDisplayName(bob, "Bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Civic pride.
|
||||
trackTaxPaid(alice, 5000)
|
||||
trackTaxPaid(bob, 100)
|
||||
civic, err := loadCivicPrideBoard(townCivicBoardLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("loadCivicPrideBoard: %v", err)
|
||||
}
|
||||
if len(civic) != 2 || civic[0].Name != "Alice" || civic[0].TotalPaid != 5000 {
|
||||
t.Fatalf("civic board wrong: %+v", civic)
|
||||
}
|
||||
|
||||
// Housing street.
|
||||
if err := upsertPlayerMetaHouseState(alice, HouseState{Tier: 4}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertPlayerMetaHouseState(bob, HouseState{Tier: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
houses, err := loadHousingStreet(townListCap)
|
||||
if err != nil {
|
||||
t.Fatalf("loadHousingStreet: %v", err)
|
||||
}
|
||||
if len(houses) != 2 || houses[0].Tier != 4 {
|
||||
t.Fatalf("housing street wrong: %+v", houses)
|
||||
}
|
||||
|
||||
// Pet showcase — only arrived, non-chased pets appear.
|
||||
if err := upsertPlayerMetaPetState(alice, PetState{Type: "dog", Name: "Rex", Level: 5, ArmorTier: 2, Arrived: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertPlayerMetaPetState(bob, PetState{Type: "cat", Name: "Ghost", Level: 3, Arrived: true, ChasedAway: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pets, err := loadPetShowcase(townListCap)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPetShowcase: %v", err)
|
||||
}
|
||||
if len(pets) != 1 || !strings.Contains(pets[0].Name, "Rex") || pets[0].ArmorName != "Chain Dog Barding" {
|
||||
t.Fatalf("pet showcase wrong (chased-away pet should be hidden): %+v", pets)
|
||||
}
|
||||
|
||||
// Graveyard.
|
||||
deadUntil := time.Now().Add(6 * time.Hour).UTC()
|
||||
if err := upsertPlayerMetaDeathState(alice, DeathState{
|
||||
Alive: false, DeadUntil: &deadUntil, LastDeathDate: "2026-07-10",
|
||||
DeathSource: "arena", DeathLocation: "the Colosseum",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
graves, err := loadGraveyard(graveyardLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("loadGraveyard: %v", err)
|
||||
}
|
||||
if len(graves) != 1 || graves[0].Name != "Alice" || !graves[0].StillDown || graves[0].Source != "arena" {
|
||||
t.Fatalf("graveyard wrong: %+v", graves)
|
||||
}
|
||||
|
||||
// Rivals board — directed pair ledger summed per user.
|
||||
upsertRivalRecord(alice, bob, true)
|
||||
upsertRivalRecord(alice, bob, true)
|
||||
upsertRivalRecord(bob, alice, false)
|
||||
board, err := loadRivalsBoard(rivalsBoardLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("loadRivalsBoard: %v", err)
|
||||
}
|
||||
if len(board) != 2 || board[0].Name != "Alice" || board[0].Wins != 2 {
|
||||
t.Fatalf("rivals board wrong: %+v", board)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTownRegistry_AllThreeBoards(t *testing.T) {
|
||||
civic := []civicEntry{{Name: "Alice", TotalPaid: 1234567}, {Name: "Bob", TotalPaid: 500}}
|
||||
houses := []housingEntry{{Name: "Alice", Tier: 4}, {Name: "Bob", Tier: 1}}
|
||||
pets := []petShowcaseEntry{{Name: "Rex (Alice)", Type: "dog", Level: 7, ArmorName: "Plate Dog Barding"}}
|
||||
|
||||
out := renderTownRegistry(civic, houses, pets)
|
||||
|
||||
for _, want := range []string{
|
||||
"Civic Pride", "€1,234,567", "Alice",
|
||||
"Housing Street", "Established", "Base House",
|
||||
"Pet Showcase", "Rex (Alice)", "L7 dog", "Plate Dog Barding",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("town registry missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTownRegistry_EmptyBoardsDontPanic(t *testing.T) {
|
||||
out := renderTownRegistry(nil, nil, nil)
|
||||
for _, want := range []string{"Civic Pride", "Housing Street", "Pet Showcase"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("empty registry missing header %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetArmorName(t *testing.T) {
|
||||
if got := petArmorName("dog", 0); got != "" {
|
||||
t.Errorf("tier 0 should be no armor, got %q", got)
|
||||
}
|
||||
if got := petArmorName("dog", 3); got != "Plate Dog Barding" {
|
||||
t.Errorf("dog tier 3 = %q, want Plate Dog Barding", got)
|
||||
}
|
||||
if got := petArmorName("cat", 5); got != "Dragonscale Cat Armor" {
|
||||
t.Errorf("cat tier 5 = %q, want Dragonscale Cat Armor", got)
|
||||
}
|
||||
if got := petArmorName("dog", 99); got != "" {
|
||||
t.Errorf("out-of-range tier should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderGraveyard_StillDownVsRecovered(t *testing.T) {
|
||||
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
deaths := []graveEntry{
|
||||
{Name: "Alice", Source: "arena", Location: "the Colosseum", LastDeathDate: "2026-07-10", StillDown: true},
|
||||
{Name: "Bob", Source: "adventure", Location: "", LastDeathDate: "2026-07-08", StillDown: false},
|
||||
}
|
||||
out := renderGraveyard(deaths, now)
|
||||
|
||||
if !strings.Contains(out, "fell in the arena") {
|
||||
t.Errorf("arena death should read distinctly\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "parts unknown") {
|
||||
t.Errorf("blank location should fall back\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "still resting") {
|
||||
t.Errorf("Alice is still down\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "back on their feet") {
|
||||
t.Errorf("Bob has recovered\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "today") || !strings.Contains(out, "2 days ago") {
|
||||
t.Errorf("relative dates missing\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderGraveyard_Empty(t *testing.T) {
|
||||
out := renderGraveyard(nil, time.Now())
|
||||
if !strings.Contains(out, "garden's quiet") {
|
||||
t.Errorf("empty graveyard should be flavored\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRivalsBoard_OrdersAndFormats(t *testing.T) {
|
||||
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
yesterday := now.Add(-24 * time.Hour)
|
||||
board := []rivalBoardEntry{
|
||||
{Name: "Alice", Wins: 9, Losses: 2, LastDuelAt: &yesterday},
|
||||
{Name: "Bob", Wins: 3, Losses: 5, LastDuelAt: nil},
|
||||
}
|
||||
out := renderRivalsBoard(board, now)
|
||||
|
||||
if !strings.Contains(out, "9W - 2L") {
|
||||
t.Errorf("Alice's record missing\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1 day ago") {
|
||||
t.Errorf("relative last-duel missing\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "last duel: —") {
|
||||
t.Errorf("Bob has no recorded duel time\n%s", out)
|
||||
}
|
||||
// Alice ranks above Bob.
|
||||
if strings.Index(out, "Alice") > strings.Index(out, "Bob") {
|
||||
t.Errorf("board should be ordered by wins\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDeathDate(t *testing.T) {
|
||||
now := time.Date(2026, 7, 10, 6, 0, 0, 0, time.UTC)
|
||||
cases := map[string]string{
|
||||
"2026-07-10": "today",
|
||||
"2026-07-09": "yesterday",
|
||||
"2026-07-05": "5 days ago",
|
||||
"": "some time ago",
|
||||
"garbage": "garbage",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := humanizeDeathDate(in, now); got != want {
|
||||
t.Errorf("humanizeDeathDate(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncName(t *testing.T) {
|
||||
if got := truncName("short", 18); got != "short" {
|
||||
t.Errorf("short name unchanged, got %q", got)
|
||||
}
|
||||
long := truncName("this-is-a-very-long-display-name", 10)
|
||||
if len([]rune(long)) != 10 {
|
||||
t.Errorf("truncated to %d runes, want 10: %q", len([]rune(long)), long)
|
||||
}
|
||||
if !strings.HasSuffix(long, "…") {
|
||||
t.Errorf("truncated name should end with ellipsis: %q", long)
|
||||
}
|
||||
}
|
||||
@@ -50,26 +50,6 @@ var advTreasureDropRates = map[int]float64{
|
||||
|
||||
const advMaxTreasures = 3
|
||||
|
||||
// houseTierTrophyRoom is the T3 "Comfortable" house that unlocks the trophy
|
||||
// room — a fourth treasure slot.
|
||||
const houseTierTrophyRoom = 3
|
||||
|
||||
// maxTreasuresForTier returns how many treasures a player may hold. The base
|
||||
// cap is 3; a T3+ house adds the trophy-room slot for a 4th.
|
||||
//
|
||||
// The extra slot is a housing perk, but HouseTier is only ever written upward
|
||||
// (purchase paths; there is no foreclosure/downgrade that writes it back down),
|
||||
// so a player can only ever hold a 4th treasure while tier>=3. There is
|
||||
// therefore no reachable state where a held treasure must be revoked at
|
||||
// bonus-computation time, and the cap is enforced only at the save gate.
|
||||
func maxTreasuresForTier(houseTier int) int {
|
||||
n := advMaxTreasures
|
||||
if houseTier >= houseTierTrophyRoom {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ── Treasure Definitions ─────────────────────────────────────────────────────
|
||||
|
||||
var advAllTreasures = map[int][]AdvTreasureDef{
|
||||
@@ -266,17 +246,12 @@ 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.
|
||||
//
|
||||
// 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) {
|
||||
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (drop *AdvTreasureDrop, roll, rate float64) {
|
||||
r, ok := advTreasureDropRates[tier]
|
||||
if !ok {
|
||||
return nil, 0, 0
|
||||
}
|
||||
rate = r*weight + chatLevelRareBonus(chatLevel)
|
||||
rate = r + chatLevelRareBonus(chatLevel)
|
||||
roll = rand.Float64()
|
||||
|
||||
if roll >= rate {
|
||||
@@ -308,7 +283,7 @@ func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int, weig
|
||||
// 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, 1)
|
||||
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel)
|
||||
return d
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxTreasuresForTier pins the T3 "trophy room" 4th slot: the base cap is
|
||||
// 3, and only a tier-3+ home lifts it to 4.
|
||||
func TestMaxTreasuresForTier(t *testing.T) {
|
||||
for tier, want := range map[int]int{0: 3, 1: 3, 2: 3, 3: 4, 4: 4} {
|
||||
if got := maxTreasuresForTier(tier); got != want {
|
||||
t.Errorf("maxTreasuresForTier(%d) = %d, want %d", tier, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func twinBeeMaxTier() int {
|
||||
if !c.Alive {
|
||||
continue
|
||||
}
|
||||
combined := combinedAdvLevel(&c)
|
||||
combined := dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
if combined > bestLevel {
|
||||
bestLevel = combined
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
weight := 4 // minimum (level 1 in all 4 skills)
|
||||
for _, c := range chars {
|
||||
if c.UserID == uid {
|
||||
weight = combinedAdvLevel(&c)
|
||||
weight = dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
if weight < 4 {
|
||||
weight = 4
|
||||
}
|
||||
@@ -322,11 +322,11 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
// ── TwinBee Gifts (Temporary Buffs) ──────────────────────────────────────────
|
||||
|
||||
type twinBeeGiftDef struct {
|
||||
BuffType string
|
||||
BuffName string
|
||||
Modifier float64
|
||||
Duration time.Duration
|
||||
Flavor string
|
||||
BuffType string
|
||||
BuffName string
|
||||
Modifier float64
|
||||
Duration time.Duration
|
||||
Flavor string
|
||||
}
|
||||
|
||||
var twinBeeGifts = []twinBeeGiftDef{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user