mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
21 Commits
n6-rivalry
...
da398cf674
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da398cf674 | ||
|
|
e98029e6ac | ||
|
|
064ecb1848 | ||
|
|
bae83271fe | ||
|
|
c9128fb0d6 | ||
|
|
055f07d3c0 | ||
|
|
27b9de5936 | ||
|
|
01c2cb2f0b | ||
|
|
d538f91cf7 | ||
|
|
f4a4c0d30b | ||
|
|
1634bb1970 | ||
|
|
ae7ff38996 | ||
|
|
b42beec348 | ||
|
|
0c4c4757d3 | ||
|
|
fedd357a29 | ||
|
|
59319ede9d | ||
|
|
3f4b4ece5c | ||
|
|
a6f1de4e74 | ||
|
|
1a47a2fdee | ||
|
|
aaa45eab14 | ||
|
|
38a3693832 |
@@ -46,7 +46,9 @@ func main() {
|
|||||||
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
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)")
|
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)")
|
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
|
||||||
|
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)")
|
||||||
|
|
||||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||||
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
||||||
@@ -60,6 +62,7 @@ func main() {
|
|||||||
|
|
||||||
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")
|
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)")
|
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()")
|
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
|
||||||
)
|
)
|
||||||
@@ -86,11 +89,51 @@ func main() {
|
|||||||
includeLog = *logFlag
|
includeLog = *logFlag
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses)
|
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// followerClasses expands -party / -party-classes into the class of each
|
||||||
@@ -119,7 +162,7 @@ func followerClasses(leaderClass string, party int, spec string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string) {
|
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string, companion string) {
|
||||||
dir := dataDir
|
dir := dataDir
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
var err error
|
var err error
|
||||||
@@ -130,7 +173,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
|
|||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers)
|
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers, companion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if res != nil {
|
if res != nil {
|
||||||
if !includeLog {
|
if !includeLog {
|
||||||
@@ -157,7 +200,7 @@ type matrixJob struct {
|
|||||||
rep int
|
rep int
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses string) {
|
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) {
|
||||||
cs := splitNonEmpty(classes)
|
cs := splitNonEmpty(classes)
|
||||||
ls := parseLevels(levels)
|
ls := parseLevels(levels)
|
||||||
zs := splitNonEmpty(zones)
|
zs := splitNonEmpty(zones)
|
||||||
@@ -188,7 +231,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < jobs; i++ {
|
for i := 0; i < jobs; i++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses)
|
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
for _, j := range work {
|
for _, j := range work {
|
||||||
@@ -207,7 +250,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses string) {
|
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()
|
defer wg.Done()
|
||||||
for j := range in {
|
for j := range in {
|
||||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
||||||
@@ -233,6 +276,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
|||||||
if partyClasses != "" {
|
if partyClasses != "" {
|
||||||
args = append(args, "-party-classes", partyClasses)
|
args = append(args, "-party-classes", partyClasses)
|
||||||
}
|
}
|
||||||
|
if companion != "" {
|
||||||
|
args = append(args, "-companion", companion)
|
||||||
|
}
|
||||||
if trace {
|
if trace {
|
||||||
args = append(args, "-trace")
|
args = append(args, "-trace")
|
||||||
}
|
}
|
||||||
@@ -272,7 +318,7 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string) (*plugin.SimResult, error) {
|
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) {
|
||||||
runner, err := plugin.NewSimRunner(dataDir)
|
runner, err := plugin.NewSimRunner(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init runner: %w", err)
|
return nil, fmt.Errorf("init runner: %w", err)
|
||||||
@@ -296,6 +342,7 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
|
|||||||
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
|
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
|
||||||
members = append(members, muid)
|
members = append(members, muid)
|
||||||
}
|
}
|
||||||
|
runner.Companion = companion
|
||||||
return runner.RunPartyExpedition(uid, members, zone, cap, days)
|
return runner.RunPartyExpedition(uid, members, zone, cap, days)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,49 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// character save, so a monotonic false→true flag can't be clobbered.
|
// character save, so a monotonic false→true flag can't be clobbered.
|
||||||
// DEFAULT 0 correct for every pre-existing row, so no bootstrap.
|
// DEFAULT 0 correct for every pre-existing row, so no bootstrap.
|
||||||
`ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`,
|
`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`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
@@ -608,6 +651,15 @@ func RunMaintenance() {
|
|||||||
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||||
{"url_cache", `DELETE FROM url_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 — purge entries older than today
|
||||||
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
||||||
|
|
||||||
@@ -939,6 +991,46 @@ CREATE TABLE IF NOT EXISTS shade_optout (
|
|||||||
user_id TEXT PRIMARY KEY
|
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
|
-- Birthdays
|
||||||
CREATE TABLE IF NOT EXISTS birthdays (
|
CREATE TABLE IF NOT EXISTS birthdays (
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
|
|||||||
230
internal/peteclient/client.go
Normal file
230
internal/peteclient/client.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// 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"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
var std *Client
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
// OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped.
|
||||||
|
db.Exec("pete emit enqueue",
|
||||||
|
`INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at)
|
||||||
|
VALUES (?, ?, unixepoch(), 0, 0)`,
|
||||||
|
f.GUID, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain sends up to senderBatch due rows, one at a time.
|
||||||
|
func (c *Client) drain(ctx context.Context) {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT guid, 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, payload string }
|
||||||
|
var batch []item
|
||||||
|
for rows.Next() {
|
||||||
|
var it item
|
||||||
|
if err := rows.Scan(&it.guid, &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.send(ctx, []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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the
|
||||||
|
// bearer-POST pattern in email_nag.go:sendCode.
|
||||||
|
func (c *Client) send(ctx context.Context, payload []byte) error {
|
||||||
|
url := c.cfg.IngestURL + "/api/ingest/adventure"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
91
internal/peteclient/client_test.go
Normal file
91
internal/peteclient/client_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -101,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
||||||
_, err := d.Exec(
|
res, err := d.Exec(
|
||||||
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
||||||
string(userID), achievementID,
|
string(userID), achievementID,
|
||||||
)
|
)
|
||||||
@@ -109,7 +110,43 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st
|
|||||||
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
||||||
return
|
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)
|
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 {
|
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||||
@@ -1206,9 +1243,39 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
|||||||
return false
|
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 ──────────────────────────────────────────
|
// ── Expedition achievement helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
|
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
|||||||
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", 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: "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: "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"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +248,10 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
// arrival roll. Both idempotent via JobCompleted gates.
|
// arrival roll. Both idempotent via JobCompleted gates.
|
||||||
bootstrapCasterSpellBackfill()
|
bootstrapCasterSpellBackfill()
|
||||||
bootstrapGrantStarterPet()
|
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
|
// 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
|
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||||
@@ -438,6 +443,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
if p.IsCommand(ctx.Body, "rivals") {
|
if p.IsCommand(ctx.Body, "rivals") {
|
||||||
return p.handleRivalsTopCmd(ctx, p.GetArgs(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)
|
// 1. Arena commands (work in rooms and DMs)
|
||||||
if p.IsCommand(ctx.Body, "bail") {
|
if p.IsCommand(ctx.Body, "bail") {
|
||||||
@@ -646,6 +654,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
|||||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||||
|
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||||
balance := p.euro.GetBalance(char.UserID)
|
balance := p.euro.GetBalance(char.UserID)
|
||||||
|
|
||||||
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
|
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
|
||||||
@@ -796,6 +805,7 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
|||||||
ForagingSkill: c.ForagingSkill,
|
ForagingSkill: c.ForagingSkill,
|
||||||
FishingSkill: c.FishingSkill,
|
FishingSkill: c.FishingSkill,
|
||||||
CurrentStreak: c.CurrentStreak,
|
CurrentStreak: c.CurrentStreak,
|
||||||
|
Renown: renownLevelForUser(c.UserID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
text := renderAdvLeaderboard(entries)
|
text := renderAdvLeaderboard(entries)
|
||||||
|
|||||||
@@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
|||||||
}
|
}
|
||||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
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.
|
// Arena tax: 10% of earnings to community pot.
|
||||||
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
||||||
arenaNet := run.Earnings - arenaTax
|
arenaNet := run.Earnings - arenaTax
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -118,6 +121,18 @@ type AdventureCharacter struct {
|
|||||||
// N5/D1c the finale reward-once flag. True after the first finale clear;
|
// N5/D1c the finale reward-once flag. True after the first finale clear;
|
||||||
// overlay-read, written by the atomic markEpilogueClearedDB.
|
// overlay-read, written by the atomic markEpilogueClearedDB.
|
||||||
EpilogueCleared bool
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdvEquipment struct {
|
type AdvEquipment struct {
|
||||||
@@ -524,6 +539,41 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
|||||||
return tx.Commit()
|
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
|
// saveAdvCharacter persists every mutable AdventureCharacter field to
|
||||||
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
|
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
|
||||||
// retired — the row is now read-only after createAdvCharacter seeds it,
|
// retired — the row is now read-only after createAdvCharacter seeds it,
|
||||||
|
|||||||
702
internal/plugin/adventure_companion.go
Normal file
702
internal/plugin/adventure_companion.go
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
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, "")
|
||||||
|
}
|
||||||
309
internal/plugin/adventure_companion_test.go
Normal file
309
internal/plugin/adventure_companion_test.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -613,6 +614,22 @@ func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, c
|
|||||||
upsertRivalRecord(winnerID, loserID, true)
|
upsertRivalRecord(winnerID, loserID, true)
|
||||||
upsertRivalRecord(loserID, winnerID, false)
|
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)
|
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
101
internal/plugin/adventure_omen.go
Normal file
101
internal/plugin/adventure_omen.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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 + "_"
|
||||||
|
}
|
||||||
86
internal/plugin/adventure_omen_test.go
Normal file
86
internal/plugin/adventure_omen_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,6 +312,20 @@ 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))
|
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
|
// Pick a morning greeting
|
||||||
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||||
displayName, _ := loadDisplayName(userID)
|
displayName, _ := loadDisplayName(userID)
|
||||||
@@ -974,6 +988,7 @@ type AdvLeaderboardEntry struct {
|
|||||||
ForagingSkill int
|
ForagingSkill int
|
||||||
FishingSkill int
|
FishingSkill int
|
||||||
CurrentStreak int
|
CurrentStreak int
|
||||||
|
Renown int // N7/B2 — prestige level, cosmetic marker only
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||||
@@ -987,16 +1002,21 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
|||||||
Score int
|
Score int
|
||||||
Levels string
|
Levels string
|
||||||
Streak int
|
Streak int
|
||||||
|
Renown int
|
||||||
}
|
}
|
||||||
var entries []entry
|
var entries []entry
|
||||||
for _, c := range chars {
|
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.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||||
|
score += c.Renown * 10
|
||||||
name, _ := loadDisplayName(c.UserID)
|
name, _ := loadDisplayName(c.UserID)
|
||||||
entries = append(entries, entry{
|
entries = append(entries, entry{
|
||||||
Name: name,
|
Name: name,
|
||||||
Score: score,
|
Score: score,
|
||||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||||
Streak: c.CurrentStreak,
|
Streak: c.CurrentStreak,
|
||||||
|
Renown: c.Renown,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,7 +1048,11 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
|||||||
if e.Streak >= 7 {
|
if e.Streak >= 7 {
|
||||||
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
|
|||||||
249
internal/plugin/adventure_renown.go
Normal file
249
internal/plugin/adventure_renown.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
238
internal/plugin/adventure_renown_test.go
Normal file
238
internal/plugin/adventure_renown_test.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
|||||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||||
|
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||||
balance := p.euro.GetBalance(char.UserID)
|
balance := p.euro.GetBalance(char.UserID)
|
||||||
|
|
||||||
holidayLabel := ""
|
holidayLabel := ""
|
||||||
|
|||||||
199
internal/plugin/adventure_season.go
Normal file
199
internal/plugin/adventure_season.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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 + "."
|
||||||
|
}
|
||||||
120
internal/plugin/adventure_season_test.go
Normal file
120
internal/plugin/adventure_season_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1146,13 +1146,38 @@ func dailyCuriosStock() []MagicItem {
|
|||||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||||
|
|
||||||
n := curiosStockSize
|
target := curiosStockSize
|
||||||
if n > len(ids) {
|
if target > len(ids) {
|
||||||
n = len(ids)
|
target = len(ids)
|
||||||
}
|
}
|
||||||
stock := make([]MagicItem, 0, n)
|
stock := make([]MagicItem, 0, target)
|
||||||
for _, id := range ids[:n] {
|
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 = append(stock, magicItemRegistry[id])
|
stock = append(stock, magicItemRegistry[id])
|
||||||
|
seen[id] = true
|
||||||
}
|
}
|
||||||
// Stable display order: rarity ascending, then name.
|
// Stable display order: rarity ascending, then name.
|
||||||
sort.Slice(stock, func(i, j int) bool {
|
sort.Slice(stock, func(i, j int) bool {
|
||||||
@@ -1167,7 +1192,11 @@ func dailyCuriosStock() []MagicItem {
|
|||||||
|
|
||||||
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
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(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||||
|
|
||||||
factor := p.shopSessionPriceFactor(userID)
|
factor := p.shopSessionPriceFactor(userID)
|
||||||
|
|||||||
227
internal/plugin/bootstrap_pete_news.go
Normal file
227
internal/plugin/bootstrap_pete_news.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootstrapPeteNewsBackfill replays the back-catalogue through the news emit
|
||||||
|
// path the first time the seam comes up enabled, so a launch doesn't open onto
|
||||||
|
// an empty section. It covers the three tractable, high-signal sources:
|
||||||
|
//
|
||||||
|
// 1. Realm-first zone clears — the earliest boss-defeated run of each zone. This
|
||||||
|
// also SEEDS news_realm_firsts, so a later live clear of an already-cleared
|
||||||
|
// zone tiers as a BULLETIN repeat, not a mis-announced "first ever".
|
||||||
|
// 2. Deaths — every player_meta row that carries a death (graveyard content).
|
||||||
|
// 3. Single-holder achievements — rarity-gated to one holder, matching the live
|
||||||
|
// milestone rule so routine unlocks don't flood the backlog.
|
||||||
|
//
|
||||||
|
// Backfilled facts are backdated to when they happened and marked NoPush (no
|
||||||
|
// web-push spam on launch). GUIDs are deterministic (keyed on the historical
|
||||||
|
// timestamp), so a re-run — or a later live emit of the same event — dedups on
|
||||||
|
// Pete's side. Guarded one-shot; kept (never deleted at close-out) so a fresh
|
||||||
|
// deploy doesn't ghost-town again. See pete_adventure_news_plan.md gap #7.
|
||||||
|
func (p *AdventurePlugin) bootstrapPeteNewsBackfill() {
|
||||||
|
const jobName = "pete_news_backfill_v1"
|
||||||
|
if db.JobCompleted(jobName, "once") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The backfill IS the launch content, so it should fire the first boot the
|
||||||
|
// seam is actually live — not get silently consumed on a boot where emission
|
||||||
|
// is off. Leave the job unmarked until both switches are on.
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
firsts := p.backfillZoneFirsts()
|
||||||
|
deaths := backfillDeaths()
|
||||||
|
achv := p.backfillSoloAchievements()
|
||||||
|
|
||||||
|
db.MarkJobCompleted(jobName, "once")
|
||||||
|
slog.Warn("bootstrap: pete news backfill complete",
|
||||||
|
"zone_firsts", firsts, "deaths", deaths, "achievements", achv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY
|
||||||
|
// realm-first dispatch per zone, attributed to its earliest boss-defeating
|
||||||
|
// clearer. SQLite returns the user_id from the same row as MIN(completed_at)
|
||||||
|
// (bare-column min/max rule), so the (zone, first clearer, time) triple is
|
||||||
|
// consistent. Returns the count emitted.
|
||||||
|
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT zone_id, user_id, MIN(completed_at)
|
||||||
|
FROM dnd_zone_run
|
||||||
|
WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0
|
||||||
|
GROUP BY zone_id`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("backfill: zone-firsts query", "err", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
type first struct {
|
||||||
|
zoneID, userID, completedAt string
|
||||||
|
}
|
||||||
|
var firsts []first
|
||||||
|
for rows.Next() {
|
||||||
|
var f first
|
||||||
|
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
|
||||||
|
slog.Error("backfill: zone-firsts scan", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
firsts = append(firsts, f)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, f := range firsts {
|
||||||
|
uid := id.UserID(f.userID)
|
||||||
|
// Seed the ledger regardless of whether we can render a dispatch, so the
|
||||||
|
// realm-first tiering is correct even for an unnamed straggler.
|
||||||
|
claimRealmFirst("zone", f.zoneID)
|
||||||
|
|
||||||
|
name := charName(uid)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, ok := parseSQLiteTime(f.completedAt)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zone := zoneOrFallback(ZoneID(f.zoneID))
|
||||||
|
lvl := charLevel(uid)
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), f.zoneID, ts.Unix()),
|
||||||
|
EventType: "zone_first",
|
||||||
|
Tier: "priority",
|
||||||
|
Subject: name,
|
||||||
|
Zone: zone.Display,
|
||||||
|
Boss: zone.Boss.Name,
|
||||||
|
Level: lvl,
|
||||||
|
Outcome: "cleared",
|
||||||
|
OccurredAt: ts.Unix(),
|
||||||
|
NoPush: true,
|
||||||
|
}, uid, "")
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillDeaths emits one death dispatch per player_meta death record. Deaths
|
||||||
|
// are day-granular (last_death_date), so the dispatch is backdated to that day.
|
||||||
|
func backfillDeaths() int {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT user_id, last_death_date, death_location
|
||||||
|
FROM player_meta
|
||||||
|
WHERE last_death_date != ''`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("backfill: deaths query", "err", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
type death struct {
|
||||||
|
userID, date, location string
|
||||||
|
}
|
||||||
|
var deaths []death
|
||||||
|
for rows.Next() {
|
||||||
|
var d death
|
||||||
|
if err := rows.Scan(&d.userID, &d.date, &d.location); err != nil {
|
||||||
|
slog.Error("backfill: deaths scan", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deaths = append(deaths, d)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, d := range deaths {
|
||||||
|
uid := id.UserID(d.userID)
|
||||||
|
name := charName(uid)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
day, err := time.Parse("2006-01-02", d.date)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lvl := charLevel(uid)
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date),
|
||||||
|
EventType: "death",
|
||||||
|
Tier: "priority",
|
||||||
|
Subject: name,
|
||||||
|
Zone: d.location,
|
||||||
|
Level: lvl,
|
||||||
|
Outcome: "lost",
|
||||||
|
OccurredAt: day.UTC().Unix(),
|
||||||
|
NoPush: true,
|
||||||
|
}, uid, "")
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillSoloAchievements emits a milestone dispatch for every achievement held
|
||||||
|
// by exactly one player — the same rarity gate the live path uses — so the
|
||||||
|
// backlog carries the genuinely distinctive unlocks, not routine ones.
|
||||||
|
func (p *AdventurePlugin) backfillSoloAchievements() int {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT achievement_id, user_id, unlocked_at
|
||||||
|
FROM achievements
|
||||||
|
GROUP BY achievement_id
|
||||||
|
HAVING COUNT(*) = 1`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("backfill: achievements query", "err", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
type solo struct {
|
||||||
|
achID, userID string
|
||||||
|
unlockedAt int64
|
||||||
|
}
|
||||||
|
var solos []solo
|
||||||
|
for rows.Next() {
|
||||||
|
var s solo
|
||||||
|
if err := rows.Scan(&s.achID, &s.userID, &s.unlockedAt); err != nil {
|
||||||
|
slog.Error("backfill: achievements scan", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
solos = append(solos, s)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, s := range solos {
|
||||||
|
uid := id.UserID(s.userID)
|
||||||
|
name := charName(uid)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := s.achID
|
||||||
|
if p.achievements != nil {
|
||||||
|
for _, a := range p.achievements.achievements {
|
||||||
|
if a.ID == s.achID {
|
||||||
|
label = a.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts := s.unlockedAt
|
||||||
|
if ts == 0 {
|
||||||
|
ts = nowUnix()
|
||||||
|
}
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(uid, s.achID), s.achID),
|
||||||
|
EventType: "milestone",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: name,
|
||||||
|
Milestone: label,
|
||||||
|
OccurredAt: ts,
|
||||||
|
NoPush: true,
|
||||||
|
}, uid, "")
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
174
internal/plugin/combat_ally_heal_test.go
Normal file
174
internal/plugin/combat_ally_heal_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// §1 — the cleric fix.
|
||||||
|
//
|
||||||
|
// Until this landed, EVERY heal in the combat engine was self-scoped:
|
||||||
|
// MistyHealProc healed the actor, HealItem fired the actor's own trigger, and
|
||||||
|
// turnActionEffect.PlayerHeal wrote to the acting seat. There was no action of any
|
||||||
|
// kind that could touch another seat's HP. A party cleric — the class whose entire
|
||||||
|
// identity is keeping other people upright — could not put one hit point on a
|
||||||
|
// friend, and N3 shipped that way without a single test going red, because party
|
||||||
|
// combat had no golden.
|
||||||
|
//
|
||||||
|
// These tests exist so that can never be true again.
|
||||||
|
|
||||||
|
// startAllyHealFight seats a healer at 0 and a hurt friend at 1.
|
||||||
|
func startAllyHealFight(t *testing.T, p *AdventurePlugin, hurtHP int) *CombatSession {
|
||||||
|
t.Helper()
|
||||||
|
healer := basePlayer()
|
||||||
|
friend := basePlayer()
|
||||||
|
enemy := Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}
|
||||||
|
|
||||||
|
// Distinct ids per fight: one active session per player is enforced, so two
|
||||||
|
// fights sharing a healer would collide.
|
||||||
|
tag := strings.ReplaceAll(t.Name(), "/", "_")
|
||||||
|
sess, err := p.startPartyCombatSession("run-"+tag, "enc", "goblin", &enemy, []CombatSeatSetup{
|
||||||
|
{UserID: healerID(tag), HP: 100, HPMax: 100, Mods: healer.Mods, C: &healer},
|
||||||
|
{UserID: friendID(tag), HP: hurtHP, HPMax: 100, Mods: friend.Mods, C: &friend},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
func healerID(tag string) id.UserID { return id.UserID("@healer-" + tag + ":example.org") }
|
||||||
|
func friendID(tag string) id.UserID { return id.UserID("@friend-" + tag + ":example.org") }
|
||||||
|
|
||||||
|
func TestAllyHeal_LandsOnTheFriendNotTheCaster(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
sess := startAllyHealFight(t, p, 20)
|
||||||
|
|
||||||
|
healer, friend := basePlayer(), basePlayer()
|
||||||
|
players := []*Combatant{&healer, &friend}
|
||||||
|
enemy := Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}
|
||||||
|
ct := &combatTurn{sess: sess, players: players, enemy: &enemy, seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||||
|
|
||||||
|
casterBefore, friendBefore := sess.seatHP(0), sess.seatHP(1)
|
||||||
|
|
||||||
|
_, err := p.driveCombatRound(ct, PlayerAction{
|
||||||
|
Kind: ActionCast,
|
||||||
|
Effect: &turnActionEffect{
|
||||||
|
Label: "Cure Wounds", Action: "spell_cast",
|
||||||
|
AllyHeal: 30, AllySeat: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := sess.seatHP(1); got != friendBefore+30 {
|
||||||
|
t.Errorf("friend HP = %d, want %d — the heal did not reach them. "+
|
||||||
|
"This is the bug: a cleric who cannot heal anyone but themselves.",
|
||||||
|
got, friendBefore+30)
|
||||||
|
}
|
||||||
|
if got := sess.seatHP(0); got > casterBefore {
|
||||||
|
t.Errorf("caster HP = %d (was %d) — the heal landed on the caster instead of the target",
|
||||||
|
got, casterBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A heal cannot exceed the target's ceiling, and cannot raise the dead. Death is
|
||||||
|
// terminal for the fight — the close-out marks them, the hospital takes them — and
|
||||||
|
// a heal that resurrected a corpse would quietly rewrite the loss rules every
|
||||||
|
// close-out path depends on.
|
||||||
|
func TestAllyHeal_CapsAtMaxAndWillNotRaiseTheDead(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
|
||||||
|
t.Run("caps at max", func(t *testing.T) {
|
||||||
|
sess := startAllyHealFight(t, p, 90)
|
||||||
|
healer, friend := basePlayer(), basePlayer()
|
||||||
|
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||||
|
enemy: &Combatant{Name: "d", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
|
||||||
|
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||||
|
|
||||||
|
if _, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
|
||||||
|
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 500, AllySeat: 1},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := sess.seatHP(1), sess.seatHPMax(1); got > want {
|
||||||
|
t.Errorf("friend healed to %d over a max of %d", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("will not raise the dead", func(t *testing.T) {
|
||||||
|
sess := startAllyHealFight(t, p, 0) // seat 1 is already down
|
||||||
|
healer, friend := basePlayer(), basePlayer()
|
||||||
|
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||||
|
enemy: &Combatant{Name: "d", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
|
||||||
|
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||||
|
|
||||||
|
if _, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
|
||||||
|
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 50, AllySeat: 1},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := sess.seatHP(1); got > 0 {
|
||||||
|
t.Errorf("a downed seat was healed to %d — healing keeps people up, it does not bring them back", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The target parser resolves against the people in the fight, and leaves anything
|
||||||
|
// it does not recognise on the string for the spell parser — so every existing
|
||||||
|
// `!cast` form still means what it always meant.
|
||||||
|
func TestSplitCastTarget(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
sess := startAllyHealFight(t, p, 50)
|
||||||
|
healer, friend := basePlayer(), basePlayer()
|
||||||
|
healer.Name, friend.Name = "Ayla", "Bram"
|
||||||
|
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||||
|
enemy: &Combatant{Name: "d"}, seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||||
|
|
||||||
|
tName := t.Name()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
wantArgs string
|
||||||
|
wantSeat int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{"no target", "cure wounds", "cure wounds", -1, false},
|
||||||
|
{"slot level is not a target", "fireball 3", "fireball 3", -1, false},
|
||||||
|
{"@mention by display name", "cure wounds @Bram", "cure wounds", 1, false},
|
||||||
|
// The flag !help has advertised all along, and that parseCombatCast used to
|
||||||
|
// accept and silently throw away ("reserved for SP3").
|
||||||
|
{"--target flag", "cure wounds --target @Bram", "cure wounds", 1, false},
|
||||||
|
{"--target mid-string", "cure wounds --target Bram", "cure wounds", 1, false},
|
||||||
|
{"--target with no name", "cure wounds --target", "", -1, true},
|
||||||
|
{"bare display name", "cure wounds Bram", "cure wounds", 1, false},
|
||||||
|
{"by localpart", "cure wounds @" + friendID(strings.ReplaceAll(tName, "/", "_")).Localpart(), "cure wounds", 1, false},
|
||||||
|
{"targeting yourself is just casting it", "cure wounds @Ayla", "cure wounds", -1, false},
|
||||||
|
{"@mention of a stranger is an error", "cure wounds @nobody", "", -1, true},
|
||||||
|
{"an unknown bare word is left for the spell parser", "mass cure wounds", "mass cure wounds", -1, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
args, seat, errMsg := splitCastTarget(ct, 0, tc.in)
|
||||||
|
if tc.wantError {
|
||||||
|
if errMsg == "" {
|
||||||
|
t.Fatalf("splitCastTarget(%q) gave no error; a mistyped @mention would silently waste the slot", tc.in)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errMsg != "" {
|
||||||
|
t.Fatalf("splitCastTarget(%q) errored: %s", tc.in, errMsg)
|
||||||
|
}
|
||||||
|
if args != tc.wantArgs || seat != tc.wantSeat {
|
||||||
|
t.Errorf("splitCastTarget(%q) = (%q, %d), want (%q, %d)",
|
||||||
|
tc.in, args, seat, tc.wantArgs, tc.wantSeat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,7 +171,12 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
|
|||||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||||
buffs, _ := loadAdvActiveBuffs(userID)
|
buffs, _ := loadAdvActiveBuffs(userID)
|
||||||
hasGrudge := char.GrudgeLocation != ""
|
hasGrudge := char.GrudgeLocation != ""
|
||||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||||
|
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
|
||||||
|
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
|
||||||
|
// which combat stat derivation never reads (see adventure_renown.go).
|
||||||
|
applyRenownBonuses(b, char.RenownLevel())
|
||||||
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
|||||||
// this is a no-op there); mirror it here so the entry banner and the opening
|
// this is a no-op there); mirror it here so the entry banner and the opening
|
||||||
// round resolve against the same ceiling startPartyCombatSession persisted and
|
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||||||
// the rebuilt rounds use.
|
// the rebuilt rounds use.
|
||||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
|
||||||
|
|
||||||
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
||||||
// per seat onto the session and its participant rows, so they survive the
|
// per seat onto the session and its participant rows, so they survive the
|
||||||
@@ -425,7 +425,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
|||||||
// returns the spell plus the resolved slot level. errMsg is non-empty and
|
// returns the spell plus the resolved slot level. errMsg is non-empty and
|
||||||
// player-facing on any validation failure. It performs NO resource spend —
|
// player-facing on any validation failure. It performs NO resource spend —
|
||||||
// the caller debits the slot only once the round is about to resolve.
|
// the caller debits the slot only once the round is about to resolve.
|
||||||
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
//
|
||||||
|
// It takes the seat, not just the user, because "do you know this spell" is a
|
||||||
|
// question about a combatant and only *usually* a question about a database row:
|
||||||
|
// the hired companion has no rows and answers it from his synthetic sheet.
|
||||||
|
func parseCombatCast(sess *CombatSession, seat int, userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
||||||
tokens := strings.Fields(args)
|
tokens := strings.Fields(args)
|
||||||
upcast := 0
|
upcast := 0
|
||||||
var spellTokens []string
|
var spellTokens []string
|
||||||
@@ -473,7 +477,7 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
|
|||||||
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
|
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
known, prepared, err := playerKnowsSpell(userID, spell.ID)
|
known, prepared, err := seatKnowsSpell(sess, seat, userID, spell.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SpellDefinition{}, 0, "Couldn't check your spell list."
|
return SpellDefinition{}, 0, "Couldn't check your spell list."
|
||||||
}
|
}
|
||||||
@@ -526,12 +530,92 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
|||||||
// the roster so the buff is live for the round's enemy turn. Before P5 that
|
// the roster so the buff is live for the round's enemy turn. Before P5 that
|
||||||
// delta went to the session's embedded copy — seat 0 — so a party member
|
// delta went to the session's embedded copy — seat 0 — so a party member
|
||||||
// buffing themselves would have buffed the leader.
|
// buffing themselves would have buffed the leader.
|
||||||
|
// splitCastTarget peels an optional ally target off the end of a `!cast` argument
|
||||||
|
// — `cure wounds @alex`, or just `cure wounds alex` — and resolves it to a seat.
|
||||||
|
//
|
||||||
|
// It resolves against the people *in this fight* rather than the room, which is
|
||||||
|
// both cheaper (no ResolveUser round-trip, no RoomID to thread down here) and
|
||||||
|
// more correct: the only legal target of a combat heal is somebody sitting in the
|
||||||
|
// combat. Anything it does not recognise is left on the string for the spell
|
||||||
|
// parser, so `!cast cure wounds` and `!cast fireball 3` behave exactly as before.
|
||||||
|
//
|
||||||
|
// Both spellings work: the `--target @alex` flag that `!help` has advertised all
|
||||||
|
// along (and that parseCombatCast has been silently swallowing since SP2 —
|
||||||
|
// "reserved for SP3, accept and ignore"), and a plain trailing `@alex`.
|
||||||
|
//
|
||||||
|
// Returns (remainingArgs, seat, errMsg). seat is -1 when no target was named.
|
||||||
|
func splitCastTarget(ct *combatTurn, caster int, args string) (string, int, string) {
|
||||||
|
args = strings.TrimSpace(args)
|
||||||
|
if args == "" || !ct.isParty() {
|
||||||
|
return args, -1, ""
|
||||||
|
}
|
||||||
|
fields := strings.Fields(args)
|
||||||
|
|
||||||
|
// `--target <who>` anywhere in the string.
|
||||||
|
explicit, name := false, ""
|
||||||
|
for i := 0; i < len(fields); i++ {
|
||||||
|
if !strings.EqualFold(fields[i], "--target") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(fields) {
|
||||||
|
return args, -1, "`--target` needs a name: `!cast cure wounds --target @alex`."
|
||||||
|
}
|
||||||
|
explicit, name = true, strings.TrimPrefix(fields[i+1], "@")
|
||||||
|
fields = append(fields[:i], fields[i+2:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
last := fields[len(fields)-1]
|
||||||
|
// A bare number is a slot level (`!cast fireball 3`), never a target.
|
||||||
|
if _, err := strconv.Atoi(last); err == nil {
|
||||||
|
return args, -1, ""
|
||||||
|
}
|
||||||
|
explicit = strings.HasPrefix(last, "@")
|
||||||
|
name = strings.TrimPrefix(last, "@")
|
||||||
|
if name == "" {
|
||||||
|
return args, -1, ""
|
||||||
|
}
|
||||||
|
fields = fields[:len(fields)-1]
|
||||||
|
}
|
||||||
|
// From here `fields` is the spell tokens with the target removed.
|
||||||
|
rest := strings.Join(fields, " ")
|
||||||
|
|
||||||
|
for i, c := range ct.players {
|
||||||
|
uid := ct.sess.seatUserID(i)
|
||||||
|
if !strings.EqualFold(c.Name, name) &&
|
||||||
|
!strings.EqualFold(uid, name) &&
|
||||||
|
!strings.EqualFold(id.UserID(uid).Localpart(), name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == caster {
|
||||||
|
// Targeting yourself is just casting it on yourself, which is what the
|
||||||
|
// engine does by default. Drop the target and carry on.
|
||||||
|
return rest, -1, ""
|
||||||
|
}
|
||||||
|
return rest, i, ""
|
||||||
|
}
|
||||||
|
// An explicit @mention that matches nobody in the fight is a mistake worth
|
||||||
|
// naming — silently casting it on yourself would waste the slot.
|
||||||
|
if explicit {
|
||||||
|
return args, -1, fmt.Sprintf("**%s** isn't in this fight. Cast it on someone who is.", name)
|
||||||
|
}
|
||||||
|
return args, -1, ""
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||||||
noop := func(bool) {}
|
noop := func(bool) {}
|
||||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||||
|
|
||||||
advChar, _ := loadAdvCharacter(uid)
|
// §1 — a heal may name somebody else in the fight: `!cast cure wounds @alex`.
|
||||||
c, err := p.ensureCharForDnDCmd(uid, advChar)
|
// Split the target off before the spell is parsed, so the spell parser sees
|
||||||
|
// the same string it always has.
|
||||||
|
args, targetSeat, targetErr := splitCastTarget(ct, seat, args)
|
||||||
|
if targetErr != "" {
|
||||||
|
return PlayerAction{}, noop, targetErr
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := p.seatCastSheet(ct.sess, uid)
|
||||||
if err != nil || c == nil {
|
if err != nil || c == nil {
|
||||||
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
|
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
|
||||||
}
|
}
|
||||||
@@ -540,7 +624,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
|
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
|
||||||
}
|
}
|
||||||
|
|
||||||
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
|
spell, slotLevel, errMsg := parseCombatCast(ct.sess, seat, uid, c, strings.TrimSpace(args))
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
return PlayerAction{}, noop, errMsg
|
return PlayerAction{}, noop, errMsg
|
||||||
}
|
}
|
||||||
@@ -551,7 +635,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
|
|
||||||
refund := func(ok bool) {
|
refund := func(ok bool) {
|
||||||
if !ok && spell.Level > 0 {
|
if !ok && spell.Level > 0 {
|
||||||
_ = refundSpellSlot(uid, slotLevel)
|
_ = refundSeatSlot(ct.sess, seat, uid, slotLevel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +652,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
return PlayerAction{}, noop, fmt.Sprintf(
|
return PlayerAction{}, noop, fmt.Sprintf(
|
||||||
"%s has no effect the turn-based engine can apply yet.", spell.Name)
|
"%s has no effect the turn-based engine can apply yet.", spell.Name)
|
||||||
}
|
}
|
||||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
|
||||||
return PlayerAction{}, noop, msg
|
return PlayerAction{}, noop, msg
|
||||||
}
|
}
|
||||||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||||||
@@ -590,7 +674,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
return PlayerAction{}, noop, fmt.Sprintf(
|
return PlayerAction{}, noop, fmt.Sprintf(
|
||||||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
|
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
|
||||||
}
|
}
|
||||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
|
||||||
return PlayerAction{}, noop, msg
|
return PlayerAction{}, noop, msg
|
||||||
}
|
}
|
||||||
// Park the Necromancy kill-heal stash on the casting seat. The
|
// Park the Necromancy kill-heal stash on the casting seat. The
|
||||||
@@ -612,6 +696,20 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
PlayerHeal: out.PlayerHeal,
|
PlayerHeal: out.PlayerHeal,
|
||||||
EnemySkip: out.EnemySkip,
|
EnemySkip: out.EnemySkip,
|
||||||
}
|
}
|
||||||
|
// §1 — redirect the heal onto the named ally. The roll is the same; only
|
||||||
|
// the body it lands on changes. This is the line that makes a cleric a
|
||||||
|
// cleric: until it existed, every heal in the engine was a self-heal, and
|
||||||
|
// the class whose whole job is keeping other people upright could not put
|
||||||
|
// a single hit point on a friend.
|
||||||
|
if targetSeat >= 0 && targetSeat != seat {
|
||||||
|
if out.PlayerHeal <= 0 {
|
||||||
|
return PlayerAction{}, refund, fmt.Sprintf(
|
||||||
|
"%s isn't something you can cast on someone else. Drop the target to cast it yourself.", spell.Name)
|
||||||
|
}
|
||||||
|
eff.AllyHeal, eff.AllySeat = out.PlayerHeal, targetSeat
|
||||||
|
eff.PlayerHeal = 0
|
||||||
|
eff.Label = fmt.Sprintf("%s → %s (+%d HP)", spell.Name, ct.players[targetSeat].Name, out.PlayerHeal)
|
||||||
|
}
|
||||||
// Concentration AOE damage spells linger: the burst lands this round
|
// Concentration AOE damage spells linger: the burst lands this round
|
||||||
// (EnemyDamage) and the same value re-ticks every round_end after, via
|
// (EnemyDamage) and the same value re-ticks every round_end after, via
|
||||||
// the engine's concentration aura. spiritual_weapon already covers the
|
// the engine's concentration aura. spiritual_weapon already covers the
|
||||||
@@ -640,18 +738,30 @@ func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
|
|||||||
// success the caller owns the slot and must refundSpellSlot if the round itself
|
// success the caller owns the slot and must refundSpellSlot if the round itself
|
||||||
// errors. Material components (rare in a fight) are not refunded if the slot
|
// errors. Material components (rare in a fight) are not refunded if the slot
|
||||||
// debit then fails — matching the auto-resolve cast path.
|
// debit then fails — matching the auto-resolve cast path.
|
||||||
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
func (p *AdventurePlugin) chargeSpellCost(sess *CombatSession, seat int, userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||||||
|
_, _, companion := seatCompanionLoadout(sess, userID)
|
||||||
|
|
||||||
|
// The companion carries no purse — he has no wallet to debit and no inventory
|
||||||
|
// to stock one from, so a component cost is not a price he can pay but a spell
|
||||||
|
// he cannot cast. Refusing here (rather than letting the debit fail on an empty
|
||||||
|
// account) keeps that an explicit rule instead of an accident of his balance.
|
||||||
if spell.MaterialCost > 0 {
|
if spell.MaterialCost > 0 {
|
||||||
|
if companion {
|
||||||
|
return fmt.Sprintf("%s needs a component %s doesn't carry.", spell.Name, companionDisplayName)
|
||||||
|
}
|
||||||
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
|
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||||
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if spell.Level > 0 {
|
if spell.Level > 0 {
|
||||||
ok, serr := consumeSpellSlot(userID, slotLevel)
|
ok, serr := consumeSeatSlot(sess, seat, userID, slotLevel)
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
return "Couldn't consume slot: " + serr.Error()
|
return "Couldn't consume slot: " + serr.Error()
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if companion {
|
||||||
|
return fmt.Sprintf("%s is out of level-%d energy.", companionDisplayName, slotLevel)
|
||||||
|
}
|
||||||
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
|
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,22 @@ type Combatant struct {
|
|||||||
Mods CombatModifiers
|
Mods CombatModifiers
|
||||||
IsPlayer bool
|
IsPlayer bool
|
||||||
Ability *MonsterAbility // non-nil for monsters with a special ability
|
Ability *MonsterAbility // non-nil for monsters with a special ability
|
||||||
|
|
||||||
|
// SeatWeight is what this seat costs the enemy: 1.0 is a full peer of the
|
||||||
|
// leader, and less than that is a seat that brings less to the fight. Zero
|
||||||
|
// means "unset" and reads as 1.0, so every existing call site — and every solo
|
||||||
|
// fight — is unchanged.
|
||||||
|
//
|
||||||
|
// The enemy's HP bump and its action economy scale on the SUM of these rather
|
||||||
|
// than on a seat count. A seat count charges the boss the same for an
|
||||||
|
// under-levelled friend, a hired NPC, and a peer, which is why hiring a
|
||||||
|
// below-median body was measurably worse than going alone: he cost a full
|
||||||
|
// seat's worth of boss and did not give a full seat's worth back.
|
||||||
|
//
|
||||||
|
// It is derived from the seat's identity (level, and whether it is a hireling),
|
||||||
|
// NOT from fight state — so every per-round rebuild recomputes the same number
|
||||||
|
// and there is nothing to persist. See seatWeight.
|
||||||
|
SeatWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type CombatPhase struct {
|
type CombatPhase struct {
|
||||||
@@ -220,7 +236,14 @@ type CombatEvent struct {
|
|||||||
//
|
//
|
||||||
// It exists so a party's play-by-play can name the right person. Solo events
|
// It exists so a party's play-by-play can name the right person. Solo events
|
||||||
// are all seat 0, and the omitempty tag keeps the field out of every solo
|
// are all seat 0, and the omitempty tag keeps the field out of every solo
|
||||||
// turn_log_json — rows written before N3/P5 decode unchanged.
|
// turn_log_json — rows written before N3/P5 decode unchanged, and a fight in
|
||||||
|
// flight across a deploy resumes byte-identically (TestP5Fields_StayOffSoloRows).
|
||||||
|
//
|
||||||
|
// The omitempty makes seat 0 and "no seat" identical on the wire, which is
|
||||||
|
// fine for persistence and actively misleading in a diagnostic trace — it hid
|
||||||
|
// a companion who never swung, making the fight look like it had one seat.
|
||||||
|
// Do NOT fix that here; the wire format is load-bearing. The sim's trace
|
||||||
|
// serializes through simTraceEvent, which always emits the seat.
|
||||||
Seat int `json:"Seat,omitempty"`
|
Seat int `json:"Seat,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatP
|
|||||||
// scaled action economy to actually threaten each member. Solo is unchanged
|
// scaled action economy to actually threaten each member. Solo is unchanged
|
||||||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||||||
if len(players) > 1 {
|
if len(players) > 1 {
|
||||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
|
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players))
|
||||||
}
|
}
|
||||||
enemyStart := enemy.Stats.MaxHP
|
enemyStart := enemy.Stats.MaxHP
|
||||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||||
@@ -307,11 +307,26 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
|
|||||||
// between — a per-round coin-flip for the extra action is the only way to land
|
// between — a per-round coin-flip for the extra action is the only way to land
|
||||||
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
||||||
// stream is untouched.
|
// stream is untouched.
|
||||||
|
// §2(b): the budget counts the seats still STANDING, re-derived every round —
|
||||||
|
// not the seats that walked in.
|
||||||
|
//
|
||||||
|
// It used to read len(st.actors), which includes the dead. So a party that lost a
|
||||||
|
// member kept paying for them: the survivor faced a boss still swinging at
|
||||||
|
// two-player cadence, alone. That is a death spiral with the arrow pointing the
|
||||||
|
// wrong way — the moment a party is losing, the engine hits it harder — and it is
|
||||||
|
// the single nastiest thing the companion sweep turned up, because it is not a
|
||||||
|
// companion bug at all. It has been live for every human party since N3.
|
||||||
|
//
|
||||||
|
// A corpse does not threaten anybody, and the enemy has no reason to keep spending
|
||||||
|
// actions on one.
|
||||||
func enemyActionsThisRound(st *combatState) int {
|
func enemyActionsThisRound(st *combatState) int {
|
||||||
n := len(st.actors)
|
if livingActors(st) < 2 {
|
||||||
if n < 2 {
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
// The summed weight of the seats still standing — not a head count of them.
|
||||||
|
// A seat that brings half a peer buys the boss half a peer's worth of extra
|
||||||
|
// swings, and a seat that is down buys none at all.
|
||||||
|
n := livingWeight(st)
|
||||||
exp := partyActionExpectation(n)
|
exp := partyActionExpectation(n)
|
||||||
base := int(exp)
|
base := int(exp)
|
||||||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||||||
@@ -320,6 +335,29 @@ func enemyActionsThisRound(st *combatState) int {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// livingActors counts the seats still standing.
|
||||||
|
func livingActors(st *combatState) int {
|
||||||
|
n := 0
|
||||||
|
for _, a := range st.actors {
|
||||||
|
if a.playerHP > 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// livingWeight is livingActors in the currency the enemy actually charges in: the
|
||||||
|
// summed SeatWeight of the seats still standing.
|
||||||
|
func livingWeight(st *combatState) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for _, a := range st.actors {
|
||||||
|
if a.playerHP > 0 {
|
||||||
|
total += combatantWeight(a.c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
// partyActionExpectation is the expected number of enemy attack-actions per round
|
// partyActionExpectation is the expected number of enemy attack-actions per round
|
||||||
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
||||||
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
||||||
@@ -333,17 +371,108 @@ func enemyActionsThisRound(st *combatState) int {
|
|||||||
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
||||||
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
||||||
// composition below its solo clear rate — bringing a friend is never a penalty.
|
// composition below its solo clear rate — bringing a friend is never a penalty.
|
||||||
func partyActionExpectation(n int) float64 {
|
// It takes a fractional party size — the summed SeatWeight of the living seats,
|
||||||
|
// not a head count — and interpolates linearly between the integer knots the P8
|
||||||
|
// sweep actually tuned: (1, 1.0), (2, 2.4), and 2n−1 from 3 up. Every integer
|
||||||
|
// input therefore returns exactly what it always returned, so a solo fight and a
|
||||||
|
// party of peers are byte-identical and the balance corpus is untouched. Only a
|
||||||
|
// roster of *unequal* seats lands between the knots, which is the entire point:
|
||||||
|
// a half-strength body should buy the boss half a body's worth of extra swings.
|
||||||
|
func partyActionExpectation(n float64) float64 {
|
||||||
switch {
|
switch {
|
||||||
case n < 2:
|
case n <= 1:
|
||||||
return 1
|
return 1
|
||||||
case n == 2:
|
case n <= 2:
|
||||||
return 2.4
|
// (1, 1.0) → (2, 2.4)
|
||||||
|
return 1 + 1.4*(n-1)
|
||||||
|
case n <= 3:
|
||||||
|
// (2, 2.4) → (3, 5.0); the original curve stepped here, so the segment is
|
||||||
|
// steeper than the 2n−1 line it joins.
|
||||||
|
return 2.4 + 2.6*(n-2)
|
||||||
default:
|
default:
|
||||||
return float64(2*n - 1)
|
return 2*n - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seatWeight is what one seat costs the enemy, relative to the leader.
|
||||||
|
//
|
||||||
|
// **Level, not stats.** A power score built from HP × damage would rank a cleric
|
||||||
|
// below a fighter and quietly make every mixed *human* party easier — the support
|
||||||
|
// classes would stop paying their way. Level is the class-neutral measure of what
|
||||||
|
// a body is worth, and it is exactly the axis on which the two seats that violated
|
||||||
|
// the peer assumption differ: an under-levelled friend, and a hireling who arrives
|
||||||
|
// a level down by design.
|
||||||
|
//
|
||||||
|
// A peer of the leader weighs 1.0, so a party of equals is unchanged. Nobody weighs
|
||||||
|
// more than a peer: out-levelling the leader does not make the boss harder for
|
||||||
|
// everyone else.
|
||||||
|
func seatWeight(seatLevel, leaderLevel int, companion bool) float64 {
|
||||||
|
if leaderLevel <= 0 || seatLevel <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
w := float64(seatLevel) / float64(leaderLevel)
|
||||||
|
if w > 1 {
|
||||||
|
w = 1
|
||||||
|
}
|
||||||
|
if companion {
|
||||||
|
// The layers a player accumulates and a hireling never will: no subclass, no
|
||||||
|
// magic items, no armed ability, and gear that is never Masterwork. Levels
|
||||||
|
// cannot see any of that, so it is priced here.
|
||||||
|
w *= companionSeatWeight
|
||||||
|
}
|
||||||
|
if w < seatWeightFloor {
|
||||||
|
w = seatWeightFloor
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// companionSeatWeight is the hireling discount — the one tunable in this model.
|
||||||
|
companionSeatWeight = 0.65
|
||||||
|
// seatWeightFloor stops a badly under-levelled seat from becoming free. A body
|
||||||
|
// in the fight is always worth something to the enemy: it is one more thing that
|
||||||
|
// has to be killed.
|
||||||
|
seatWeightFloor = 0.35
|
||||||
|
)
|
||||||
|
|
||||||
|
// partyWeight sums the seats' weights. An unset weight (0) reads as a full peer,
|
||||||
|
// which is what every combatant built before this existed is.
|
||||||
|
func partyWeight(players []*Combatant) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for _, c := range players {
|
||||||
|
total += combatantWeight(c)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func combatantWeight(c *Combatant) float64 {
|
||||||
|
if c == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if c.SeatWeight <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return c.SeatWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// partyWeightOf is partyWeight for a value slice.
|
||||||
|
func partyWeightOf(players []Combatant) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for i := range players {
|
||||||
|
total += combatantWeight(&players[i])
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatSetupWeight is partyWeight for a roster that is still being seated.
|
||||||
|
func seatSetupWeight(seats []CombatSeatSetup) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for _, s := range seats {
|
||||||
|
total += combatantWeight(s.C)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
||||||
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
||||||
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
||||||
@@ -355,18 +484,27 @@ func partyActionExpectation(n int) float64 {
|
|||||||
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
||||||
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||||||
// not the 100% faceroll a single enemy swing produced.
|
// not the 100% faceroll a single enemy swing produced.
|
||||||
func partyEnemyHPScale(rosterSize int) float64 {
|
// Like partyActionExpectation it takes the summed SeatWeight rather than a head
|
||||||
if rosterSize < 2 {
|
// count, and ramps between the same knots: weight 1 (solo) pays nothing, weight 2
|
||||||
|
// (a peer at the leader's side) pays the full 1.15 the P8 sweep tuned. Integer
|
||||||
|
// inputs are byte-exact, so solo and a party of peers are unchanged; a roster
|
||||||
|
// carrying a below-median body pays proportionally less, because it brought
|
||||||
|
// proportionally less.
|
||||||
|
func partyEnemyHPScale(weight float64) float64 {
|
||||||
|
if weight <= 1 {
|
||||||
return 1.0
|
return 1.0
|
||||||
}
|
}
|
||||||
return 1.15
|
if weight >= 2 {
|
||||||
|
return 1.15
|
||||||
|
}
|
||||||
|
return 1.0 + 0.15*(weight-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
||||||
// rule, so every call site (the auto-resolve engine, the party session's initial
|
// rule, so every call site (the auto-resolve engine, the party session's initial
|
||||||
// persist, and the per-turn rebuild) agrees on the same number.
|
// persist, and the per-turn rebuild) agrees on the same number.
|
||||||
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
|
func scaledEnemyMaxHP(baseMaxHP int, weight float64) int {
|
||||||
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
|
return int(float64(baseMaxHP) * partyEnemyHPScale(weight))
|
||||||
}
|
}
|
||||||
|
|
||||||
// enemyActionPlan is the shared action budget both combat engines resolve an
|
// enemyActionPlan is the shared action budget both combat engines resolve an
|
||||||
|
|||||||
@@ -142,11 +142,22 @@ func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
|
|||||||
if got := partyActionExpectation(2); got != 2.4 {
|
if got := partyActionExpectation(2); got != 2.4 {
|
||||||
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
||||||
}
|
}
|
||||||
|
// The curve now takes a fractional weight rather than a head count, so that a
|
||||||
|
// below-median seat costs the enemy less than a peer does. Every INTEGER input
|
||||||
|
// must still return exactly what it always returned — that is what keeps solo
|
||||||
|
// and a party of peers byte-identical, and the balance corpus with them.
|
||||||
for n := 3; n <= 5; n++ {
|
for n := 3; n <= 5; n++ {
|
||||||
if got, want := partyActionExpectation(n), float64(2*n-1); got != want {
|
if got, want := partyActionExpectation(float64(n)), float64(2*n-1); got != want {
|
||||||
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
|
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A duo carrying a half-strength body sits between soloing and a true duo.
|
||||||
|
if got := partyActionExpectation(1.5); got <= 1 || got >= 2.4 {
|
||||||
|
t.Fatalf("weight 1.5 action expectation = %v, want strictly between 1 and 2.4", got)
|
||||||
|
}
|
||||||
|
if got := partyEnemyHPScale(1.5); got <= 1.0 || got >= 1.15 {
|
||||||
|
t.Fatalf("weight 1.5 HP scale = %v, want strictly between 1.0 and 1.15", got)
|
||||||
|
}
|
||||||
if got := partyEnemyHPScale(3); got != 1.15 {
|
if got := partyEnemyHPScale(3); got != 1.15 {
|
||||||
t.Fatalf("party HP scale = %v, want 1.15", got)
|
t.Fatalf("party HP scale = %v, want 1.15", got)
|
||||||
}
|
}
|
||||||
|
|||||||
159
internal/plugin/combat_party_characterization_test.go
Normal file
159
internal/plugin/combat_party_characterization_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Party characterization — the net that did not exist.
|
||||||
|
//
|
||||||
|
// TestCombatCharacterization pins SOLO combat exhaustively, and it has been the
|
||||||
|
// tripwire for every balance change since. Party combat had nothing. The whole
|
||||||
|
// N-body layer — initiative order, per-seat resolution, enemy targeting, the P8
|
||||||
|
// action-economy and HP scaling, downed-seat handling — shipped unpinned.
|
||||||
|
//
|
||||||
|
// What that cost: N3 shipped a Cleric class that cannot heal a single ally (no
|
||||||
|
// action in the engine can target another seat) and not one test went red. The
|
||||||
|
// hired companion then stood in fights doing nothing, and the unit tests stayed
|
||||||
|
// green through that too. It took a 1500-run expedition sweep to see either.
|
||||||
|
//
|
||||||
|
// So: pin it. Any change to the party engine now has to state, in the diff, what
|
||||||
|
// it moved. Regenerate ONLY on purpose:
|
||||||
|
//
|
||||||
|
// go test ./internal/plugin -run TestPartyCharacterization -update
|
||||||
|
//
|
||||||
|
// This golden covers simulateParty — the auto-resolve engine, which decides most
|
||||||
|
// expedition rooms and which the balance harness is built on. The turn engine's
|
||||||
|
// party half (manual play + boss fights) needs DB fixtures and is pinned by
|
||||||
|
// combat_turn_party_test.go; widening THAT into a golden is the follow-up.
|
||||||
|
|
||||||
|
var updatePartyGolden = flag.Bool("update-party", false, "rewrite the party characterization golden")
|
||||||
|
|
||||||
|
// partyScenario is one pinned N-body fight.
|
||||||
|
type partyScenario struct {
|
||||||
|
name string
|
||||||
|
seats []Combatant
|
||||||
|
enemy Combatant
|
||||||
|
phases []CombatPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatOfClass shapes a seat that stands in for a class archetype. These are
|
||||||
|
// deliberately engine-level (stat blocks, not sheets): the golden pins what the
|
||||||
|
// ENGINE does with a roster, not what the character layer feeds it.
|
||||||
|
func seatOfClass(name string, hp, atk, def, speed int) Combatant {
|
||||||
|
c := basePlayer()
|
||||||
|
c.Name = name
|
||||||
|
c.Stats.MaxHP = hp
|
||||||
|
c.Stats.Attack = atk
|
||||||
|
c.Stats.Defense = def
|
||||||
|
c.Stats.Speed = speed
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func partyCharacterizationScenarios() []partyScenario {
|
||||||
|
tank := seatOfClass("Tank", 160, 14, 12, 8)
|
||||||
|
striker := seatOfClass("Striker", 90, 22, 6, 14)
|
||||||
|
support := seatOfClass("Support", 110, 11, 9, 10)
|
||||||
|
// The seat this whole plan is about: a body that is real but below median.
|
||||||
|
// If scaling ever stops overcharging for it, THIS line is what moves.
|
||||||
|
weak := seatOfClass("Weak", 70, 8, 5, 9)
|
||||||
|
// A seat that will fall early. Its corpse must not keep buffing the enemy —
|
||||||
|
// when §2 lands, this scenario is the one that proves it.
|
||||||
|
glass := seatOfClass("Glass", 12, 18, 0, 16)
|
||||||
|
|
||||||
|
return []partyScenario{
|
||||||
|
{"duo/even", []Combatant{tank, striker}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"duo/tank+support", []Combatant{tank, support}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"duo/median+weak", []Combatant{tank, weak}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"duo/glass falls early", []Combatant{tank, glass}, hardHitEnemy(), dungeonCombatPhases},
|
||||||
|
{"trio/even", []Combatant{tank, striker, support}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"trio/one weak seat", []Combatant{tank, striker, weak}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"trio/two glass seats", []Combatant{tank, glass, glass}, hardHitEnemy(), dungeonCombatPhases},
|
||||||
|
{"duo/vs ability enemy", []Combatant{tank, striker},
|
||||||
|
abilityEnemy("Wither", "poison", "Duel"), dungeonCombatPhases},
|
||||||
|
// The degenerate case. A one-seat roster MUST stay bit-identical to solo —
|
||||||
|
// it is the invariant the entire balance corpus rests on, and the reason
|
||||||
|
// the solo golden is allowed to stay untouched while this file grows.
|
||||||
|
{"solo/one-seat roster", []Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPartyResult prints the seat on EVERY line. The solo formatter does not
|
||||||
|
// (it cannot; there is only one seat), and CombatEvent.Seat is `omitempty`, so a
|
||||||
|
// JSON trace renders seat 0 and "no seat" identically — which sent me chasing a
|
||||||
|
// phantom "the companion never swings" bug for an hour. A party golden that
|
||||||
|
// could not tell you WHO acted would be worth very little.
|
||||||
|
func formatPartyResult(r PartyCombatResult) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "result: won=%v timedOut=%v rounds=%d survivors=%v\n",
|
||||||
|
r.PlayerWon, r.TimedOut, r.TotalRounds, r.AnySurvivor())
|
||||||
|
fmt.Fprintf(&b, " enemy: start=%d entry=%d end=%d\n", r.EnemyStartHP, r.EnemyEntryHP, r.EnemyEndHP)
|
||||||
|
for i, s := range r.Seats {
|
||||||
|
fmt.Fprintf(&b, " seat[%d]: start=%d entry=%d end=%d\n",
|
||||||
|
i, s.PlayerStartHP, s.PlayerEntryHP, s.PlayerEndHP)
|
||||||
|
}
|
||||||
|
for i, e := range r.Events {
|
||||||
|
fmt.Fprintf(&b, " [%02d] r%d seat=%d %-12s %-8s %-16s dmg=%-5d php=%-4d ehp=%-4d roll=%d/%d desc=%q\n",
|
||||||
|
i, e.Round, e.Seat, e.Phase, e.Actor, e.Action, e.Damage, e.PlayerHP, e.EnemyHP,
|
||||||
|
e.Roll, e.RollAgainst, e.Desc)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartyCharacterization(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, sc := range partyCharacterizationScenarios() {
|
||||||
|
for _, seed := range charSeeds {
|
||||||
|
rng := rand.New(rand.NewPCG(seed, 0xC0FFEE))
|
||||||
|
res := simulatePartyWithRNG(sc.seats, sc.enemy, sc.phases, rng)
|
||||||
|
fmt.Fprintf(&b, "=== %s seed=%d ===\n", sc.name, seed)
|
||||||
|
b.WriteString(formatPartyResult(res))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
|
||||||
|
path := filepath.Join("testdata", "party_characterization.golden")
|
||||||
|
if *updatePartyGolden {
|
||||||
|
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log("party golden rewritten:", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read party golden (run with -update-party to create it): %v", err)
|
||||||
|
}
|
||||||
|
if string(want) != got {
|
||||||
|
t.Fatalf("PARTY ENGINE BEHAVIOUR MOVED.\n\n%s\n\n"+
|
||||||
|
"If this was deliberate, say so in the commit and regenerate:\n"+
|
||||||
|
" go test ./internal/plugin -run TestPartyCharacterization -update-party",
|
||||||
|
firstDiff(string(want), got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-seat roster is the solo engine. If this ever fails, the N-body path has
|
||||||
|
// stopped being a superset of the path the entire balance corpus was measured on,
|
||||||
|
// and every baseline in the repo is suspect.
|
||||||
|
func TestPartyCharacterization_OneSeatIsStillSolo(t *testing.T) {
|
||||||
|
for _, seed := range charSeeds {
|
||||||
|
solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases,
|
||||||
|
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
|
||||||
|
party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases,
|
||||||
|
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
|
||||||
|
|
||||||
|
if solo.PlayerWon != party.PlayerWon || solo.TotalRounds != party.TotalRounds ||
|
||||||
|
solo.EnemyEndHP != party.EnemyEndHP || solo.PlayerEndHP != party.Seats[0].PlayerEndHP {
|
||||||
|
t.Fatalf("seed %d: a one-seat roster diverged from solo\n solo: won=%v rounds=%d ehp=%d php=%d\n party: won=%v rounds=%d ehp=%d php=%d",
|
||||||
|
seed,
|
||||||
|
solo.PlayerWon, solo.TotalRounds, solo.EnemyEndHP, solo.PlayerEndHP,
|
||||||
|
party.PlayerWon, party.TotalRounds, party.EnemyEndHP, party.Seats[0].PlayerEndHP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,18 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
|||||||
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
||||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||||
for seat := range sess.RosterSize() {
|
for seat := range sess.RosterSize() {
|
||||||
|
// The companion has no sheet, so none of the sheet-keyed bookkeeping below
|
||||||
|
// applies to him — and postCombatBookkeepingForSeat logs at ERROR for a
|
||||||
|
// seat with no sheet, which would file one for every party fight he is ever
|
||||||
|
// hired for. But his HP is not bookkeeping: it is the fight's result. It
|
||||||
|
// lands on his roster row, because that is the only row he has.
|
||||||
|
//
|
||||||
|
// He used to be skipped outright, and "he arrives fresh next time" was the
|
||||||
|
// stated intent. It is a free lunch — see companionSeatHP.
|
||||||
|
if isCompanionUser(sess.seatUserID(seat)) {
|
||||||
|
_ = setCompanionHPForRun(sess.RunID, sess.seatHP(seat))
|
||||||
|
continue
|
||||||
|
}
|
||||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||||
p.postCombatBookkeepingForSeat(sess, seat)
|
p.postCombatBookkeepingForSeat(sess, seat)
|
||||||
}
|
}
|
||||||
@@ -100,6 +112,16 @@ func (p *AdventurePlugin) finishPartyWin(
|
|||||||
uid := id.UserID(sess.seatUserID(seat))
|
uid := id.UserID(sess.seatUserID(seat))
|
||||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||||
|
|
||||||
|
// The hired companion takes no cut. He earns no XP (there is no sheet to
|
||||||
|
// put it on), rolls no loot (dropZoneLoot writes real inventory rows for
|
||||||
|
// whatever id it is handed, and a bot with a magic sword is nobody's
|
||||||
|
// intent), and takes no death row. His seat renders nothing: he is not
|
||||||
|
// reading this. He was paid up front.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
out[seat] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// A member who went down before the killing blow still earns the kill.
|
// A member who went down before the killing blow still earns the kill.
|
||||||
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
||||||
|
|
||||||
@@ -148,7 +170,17 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
|
|||||||
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
||||||
out := make([]string, sess.RosterSize())
|
out := make([]string, sess.RosterSize())
|
||||||
for seat := range sess.RosterSize() {
|
for seat := range sess.RosterSize() {
|
||||||
markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display)
|
uid := id.UserID(sess.seatUserID(seat))
|
||||||
|
// The companion does not die. markAdventureDead is a silent no-op for him
|
||||||
|
// today (no player_meta row to mark), but relying on that accident is how
|
||||||
|
// he ends up in the graveyard the first time someone gives him a row —
|
||||||
|
// and emitDeathNews would have the news bot file a death notice about
|
||||||
|
// itself. Say it out loud instead.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
out[seat] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
markAdventureDead(uid, "zone", zone.Display)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if line != "" && seat == 0 {
|
if line != "" && seat == 0 {
|
||||||
b.WriteString(line + "\n")
|
b.WriteString(line + "\n")
|
||||||
@@ -220,9 +252,9 @@ func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = abandonZoneRun(owner)
|
_ = abandonZoneRun(owner)
|
||||||
reason := "combat flee"
|
reason := lossCombatFlee
|
||||||
if death {
|
if death {
|
||||||
reason = "combat death"
|
reason = lossCombatDeath
|
||||||
}
|
}
|
||||||
forceExtractExpeditionForRunLoss(owner, reason)
|
forceExtractExpeditionForRunLoss(owner, reason)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ func fightRoster(sender id.UserID) []id.UserID {
|
|||||||
if err != nil || e == nil {
|
if err != nil || e == nil {
|
||||||
return []id.UserID{sender}
|
return []id.UserID{sender}
|
||||||
}
|
}
|
||||||
return expeditionAudience(e)
|
// Seats, not audience: the hired companion fights even though he never
|
||||||
|
// receives a DM about it.
|
||||||
|
return expeditionSeats(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFightSeats turns a roster into the seats that will actually sit down, and
|
// buildFightSeats turns a roster into the seats that will actually sit down, and
|
||||||
@@ -56,9 +58,40 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
senderSkip = why
|
senderSkip = why
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Parallel to `seats`, so a skipped member leaves no gap: what a seat costs the
|
||||||
|
// enemy is priced from these once the roster is final.
|
||||||
|
var levels []int
|
||||||
|
var companions []bool
|
||||||
for i, uid := range roster {
|
for i, uid := range roster {
|
||||||
leader := i == 0
|
leader := i == 0
|
||||||
|
|
||||||
|
// The hired companion. He must be handled before everything below:
|
||||||
|
// dndHPSnapshot returns (0,0) for a user with no sheet, so the very next
|
||||||
|
// check would quietly sit him out of every fight he was paid for, and
|
||||||
|
// buildZoneCombatants would then fail on him anyway.
|
||||||
|
//
|
||||||
|
// He is latched onto autopilot at seat time rather than after the away-player
|
||||||
|
// deadline — nobody is going to type for him, and waiting three minutes to
|
||||||
|
// discover that would stall the fight and then announce him to the party as
|
||||||
|
// an absent player.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
expID := companionExpeditionFor(roster[0])
|
||||||
|
class, level := companionLoadout(expID)
|
||||||
|
player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
||||||
|
// He carries his wounds between fights, like everyone else. Seating him at
|
||||||
|
// full max HP — which is what this did — hands the party an infinite body:
|
||||||
|
// he soaks a share of every fight's incoming and then resets, while the
|
||||||
|
// humans beside him bleed all the way to camp.
|
||||||
|
seats = append(seats, CombatSeatSetup{
|
||||||
|
UserID: uid,
|
||||||
|
HP: companionSeatHP(expID, player.Stats.MaxHP),
|
||||||
|
HPMax: player.Stats.MaxHP,
|
||||||
|
Mods: player.Mods, C: &player, EngineDriven: true,
|
||||||
|
})
|
||||||
|
levels, companions = append(levels, level), append(companions, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Both refusals below are cheap and neither needs the build, so they run
|
// Both refusals below are cheap and neither needs the build, so they run
|
||||||
// before it: consuming a seat's armed ability and *then* sitting them out
|
// before it: consuming a seat's armed ability and *then* sitting them out
|
||||||
// would spend their rage on a fight they never joined.
|
// would spend their rage on a fight they never joined.
|
||||||
@@ -94,7 +127,7 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
trySimAutoArm(c)
|
trySimAutoArm(c)
|
||||||
armed = consumeArmedAbility(c)
|
armed = consumeArmedAbility(c)
|
||||||
}
|
}
|
||||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
player, e, dc, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if leader {
|
if leader {
|
||||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||||
@@ -113,7 +146,17 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
seats = append(seats, CombatSeatSetup{
|
seats = append(seats, CombatSeatSetup{
|
||||||
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
||||||
})
|
})
|
||||||
|
lvl := 0
|
||||||
|
if dc != nil {
|
||||||
|
lvl = dc.Level
|
||||||
|
}
|
||||||
|
levels, companions = append(levels, lvl), append(companions, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Price each seat against the leader. It runs here, over the seats that were
|
||||||
|
// actually seated — a member who was skipped (downed, busy elsewhere) never
|
||||||
|
// joined the fight and must not be charged to the enemy.
|
||||||
|
applySeatWeights(seatCombatants(seats), levels, companions)
|
||||||
return seats, enemy, senderSkip, ""
|
return seats, enemy, senderSkip, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +202,12 @@ func (p *AdventurePlugin) announcePartyFightStart(
|
|||||||
names[i] = c.Name
|
names[i] = c.Name
|
||||||
}
|
}
|
||||||
for seat, uid := range sess.SeatUserIDs() {
|
for seat, uid := range sess.SeatUserIDs() {
|
||||||
|
// Seat-keyed fan-out, so it bypasses expeditionAudience's filter — the
|
||||||
|
// companion sits down but is never written to. (He also has no magic items
|
||||||
|
// to line up, and activeMagicItemsLine would go looking for them.)
|
||||||
|
if isCompanionUser(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(header)
|
b.WriteString(header)
|
||||||
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))
|
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))
|
||||||
|
|||||||
@@ -166,6 +166,15 @@ func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (
|
|||||||
return fail(notYourTurnMsg(players, acting, waiting))
|
return fail(notYourTurnMsg(players, acting, waiting))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An engine-driven seat has nobody to hand the wheel back to. A command
|
||||||
|
// arriving from one is not a player returning to the keyboard — it is a driver
|
||||||
|
// impersonating a seat, which is precisely the confusion that used to strand
|
||||||
|
// the companion: his own auto-played turn came back through here looking like
|
||||||
|
// a keystroke and cleared the latch that was moving him. Refuse it outright.
|
||||||
|
if sess.seatIsEngineDriven(seat) {
|
||||||
|
return fail("That seat isn't yours to play.")
|
||||||
|
}
|
||||||
|
|
||||||
// They typed, so they are here. Hand back the wheel.
|
// They typed, so they are here. Hand back the wheel.
|
||||||
sess.actorStatusesPtr(seat).Autopilot = false
|
sess.actorStatusesPtr(seat).Autopilot = false
|
||||||
|
|
||||||
@@ -315,6 +324,33 @@ func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
|
|||||||
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
|
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// driveEngineSeat plays one engine-driven seat's turn and persists the result.
|
||||||
|
// It is the seat-driver equivalent of a player typing `!attack`, for a seat that
|
||||||
|
// will never type anything.
|
||||||
|
//
|
||||||
|
// It exists because the alternative — dispatching a combat command as that seat —
|
||||||
|
// sends the turn back through beginCombatTurn, which reads any command from a
|
||||||
|
// seat as "that player is back". For a human that is correct. For a seat with no
|
||||||
|
// human it is fatal: it drops the latch that is the only thing moving them.
|
||||||
|
func (p *AdventurePlugin) driveEngineSeat(sess *CombatSession, seat int) error {
|
||||||
|
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rebuild for engine seat: %w", err)
|
||||||
|
}
|
||||||
|
ct := &combatTurn{
|
||||||
|
sess: sess, players: players, enemy: enemy,
|
||||||
|
seat: seat, uid: id.UserID(sess.seatUserID(seat)),
|
||||||
|
}
|
||||||
|
if _, err := p.runAutoSeatTurn(ct, seat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveCombatSession(sess); err != nil {
|
||||||
|
return fmt.Errorf("save after engine seat turn: %w", err)
|
||||||
|
}
|
||||||
|
p.closePartyRound(ct)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
|
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
|
||||||
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
|
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
|
||||||
// save that parked the fight on this seat's player_turn was the last one — so it
|
// save that parked the fight on this seat's player_turn was the last one — so it
|
||||||
@@ -387,6 +423,12 @@ func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEven
|
|||||||
names := ct.seatNames()
|
names := ct.seatNames()
|
||||||
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
||||||
for seat, uid := range ct.sess.SeatUserIDs() {
|
for seat, uid := range ct.sess.SeatUserIDs() {
|
||||||
|
// The combat fan-out is seat-keyed, so it does not pass through
|
||||||
|
// expeditionAudience and does not inherit its companion filter. He fights;
|
||||||
|
// he is not written to about it.
|
||||||
|
if isCompanionUser(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Rendered once per reader: the flavor pool speaks in the second person,
|
// Rendered once per reader: the flavor pool speaks in the second person,
|
||||||
// so each member's own events must be theirs and nobody else's.
|
// so each member's own events must be theirs and nobody else's.
|
||||||
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
|
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
|
||||||
|
|||||||
174
internal/plugin/combat_seat_spells.go
Normal file
174
internal/plugin/combat_seat_spells.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seat-scoped spellbook.
|
||||||
|
//
|
||||||
|
// Every spell lookup in the engine — known list, prepared flag, slot pool, slot
|
||||||
|
// spend — is keyed on a Matrix user id and answered by a `dnd_*` table. That was
|
||||||
|
// fine while every combatant was a player. The hired companion is not: he has no
|
||||||
|
// row in dnd_character, dnd_known_spells or dnd_spell_slots, deliberately, because
|
||||||
|
// a sheet on disk for him is the thing that would turn him into a real character
|
||||||
|
// everywhere (player_meta, the leaderboard, !stats). His sheet is synthesized per
|
||||||
|
// fight and thrown away.
|
||||||
|
//
|
||||||
|
// So every one of those lookups returned "nothing" for him, and the picker's very
|
||||||
|
// first line — `c, _ := LoadDnDCharacter(uid); if c == nil { return "attack" }` —
|
||||||
|
// sent him to swing a mace, every turn, forever. A hired Cleric could not heal.
|
||||||
|
// Role-fill hands a lone fighter a Cleric, so that was the *common* case.
|
||||||
|
//
|
||||||
|
// These are the seat-scoped forms of those lookups. A human seat delegates to the
|
||||||
|
// DB functions verbatim — same queries, same order, so solo combat and the balance
|
||||||
|
// corpus are untouched. A companion seat is answered from his in-memory sheet and
|
||||||
|
// a slot ledger on his seat's persisted statuses.
|
||||||
|
//
|
||||||
|
// Nothing here may write a row for the companion. That invariant is what
|
||||||
|
// TestCompanion_SheetIsBelowMedianAndNeverPersisted pins, and the auto-migration
|
||||||
|
// inside ensureCharForDnDCmd would violate it silently: handed a user with no
|
||||||
|
// sheet, it *builds one at level 1 and saves it*. Hence seatCastSheet.
|
||||||
|
|
||||||
|
// seatCompanionLoadout returns the class and level a companion seat fights as,
|
||||||
|
// and whether the seat is the companion at all.
|
||||||
|
func seatCompanionLoadout(sess *CombatSession, uid id.UserID) (DnDClass, int, bool) {
|
||||||
|
if sess == nil || !isCompanionSeat(uid) {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
class, level := companionLoadoutForRun(sess.RunID)
|
||||||
|
return class, level, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatCastSheet resolves the character behind a seat for the !cast path.
|
||||||
|
//
|
||||||
|
// A human goes through ensureCharForDnDCmd, exactly as before — including its
|
||||||
|
// auto-migration for a player who predates Adv 2.0. The companion must NOT: that
|
||||||
|
// migration would mint and persist a level-1 dnd_character row for him, quietly
|
||||||
|
// making him a player and throwing away the class and level he was hired at. He
|
||||||
|
// gets his synthetic sheet instead, built from the same class-priority pipeline a
|
||||||
|
// real character of his level uses.
|
||||||
|
func (p *AdventurePlugin) seatCastSheet(sess *CombatSession, uid id.UserID) (*DnDCharacter, error) {
|
||||||
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
return companionSheet(class, level), nil
|
||||||
|
}
|
||||||
|
advChar, _ := loadAdvCharacter(uid)
|
||||||
|
return p.ensureCharForDnDCmd(uid, advChar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatPickSheet is seatCastSheet for the auto-picker, which reads a sheet to
|
||||||
|
// decide a turn and must never create one. Humans get LoadDnDCharacter, which is
|
||||||
|
// what the picker has always called; a miss returns nil and the caller swings.
|
||||||
|
func seatPickSheet(sess *CombatSession, uid id.UserID) *DnDCharacter {
|
||||||
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
return companionSheet(class, level)
|
||||||
|
}
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionKnownSpells is the companion's spell list: the same default kit
|
||||||
|
// ensureSpellsForCharacter grants a real character of that class and level, every
|
||||||
|
// entry prepared (which is also what that function does — preparation is SP4 and
|
||||||
|
// until it lands every granted spell is auto-prepared so !cast works).
|
||||||
|
//
|
||||||
|
// He gets the player kit on purpose. His below-median comes from the level penalty,
|
||||||
|
// the never-Masterwork gear and the absent subclass/magic items — the layers a
|
||||||
|
// player accumulates. Handing him a bespoke, weaker spell list would be a second
|
||||||
|
// nerf hidden in a different file, and it would drift away from the tuned list the
|
||||||
|
// moment anyone touched one and not the other.
|
||||||
|
func companionKnownSpells(class DnDClass, level int) []knownSpellRow {
|
||||||
|
ids := defaultKnownSpells(class, level)
|
||||||
|
out := make([]knownSpellRow, 0, len(ids))
|
||||||
|
for _, sid := range ids {
|
||||||
|
out = append(out, knownSpellRow{SpellID: sid, Source: "companion", Prepared: true})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionSlotPool is his slot table (total, used) — the class/level progression
|
||||||
|
// every caster shares, less what he has already spent. `used` is the expedition's
|
||||||
|
// ledger, NOT a per-fight one: he rations one pool across the run and gets it back
|
||||||
|
// at camp, exactly as a human caster does.
|
||||||
|
func companionSlotPool(class DnDClass, level int, used [6]int) map[int][2]int {
|
||||||
|
out := map[int][2]int{}
|
||||||
|
for lvl, total := range slotsForClassLevel(class, level) {
|
||||||
|
spent := 0
|
||||||
|
if lvl >= 0 && lvl < len(used) {
|
||||||
|
spent = min(used[lvl], total)
|
||||||
|
}
|
||||||
|
out[lvl] = [2]int{total, spent}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatKnownSpells is listKnownSpells for a seat.
|
||||||
|
func seatKnownSpells(sess *CombatSession, seat int, uid id.UserID) ([]knownSpellRow, error) {
|
||||||
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
return companionKnownSpells(class, level), nil
|
||||||
|
}
|
||||||
|
return listKnownSpells(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatSpellSlots is getSpellSlots for a seat.
|
||||||
|
func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) {
|
||||||
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
return companionSlotPool(class, level, companionSlotsForRun(sess.RunID)), nil
|
||||||
|
}
|
||||||
|
return getSpellSlots(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatKnowsSpell is playerKnowsSpell for a seat: (known, prepared, err).
|
||||||
|
func seatKnowsSpell(sess *CombatSession, seat int, uid id.UserID, spellID string) (bool, bool, error) {
|
||||||
|
if _, _, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
known, err := seatKnownSpells(sess, seat, uid)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
for _, k := range known {
|
||||||
|
if k.SpellID == spellID {
|
||||||
|
return true, k.Prepared, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false, nil
|
||||||
|
}
|
||||||
|
return playerKnowsSpell(uid, spellID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeSeatSlot is consumeSpellSlot for a seat. The companion's spend lands on
|
||||||
|
// the expedition's ledger — one pool for the whole run, refilled at camp — so he
|
||||||
|
// rations his slots the way a human caster has to. Keying it by expedition also
|
||||||
|
// keeps two parties who have each hired him from sharing a pool, which anything
|
||||||
|
// keyed on his (single, shared) user id would have done.
|
||||||
|
func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) {
|
||||||
|
class, level, ok := seatCompanionLoadout(sess, uid)
|
||||||
|
if !ok {
|
||||||
|
return consumeSpellSlot(uid, slotLevel)
|
||||||
|
}
|
||||||
|
used := companionSlotsForRun(sess.RunID)
|
||||||
|
if slotLevel < 1 || slotLevel >= len(used) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
pair, exists := companionSlotPool(class, level, used)[slotLevel]
|
||||||
|
if !exists || pair[0]-pair[1] <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
used[slotLevel]++
|
||||||
|
if err := setCompanionSlotsForRun(sess.RunID, used); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundSeatSlot is refundSpellSlot for a seat: the rollback half of the above,
|
||||||
|
// called when the round the slot was spent on failed to resolve.
|
||||||
|
func refundSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) error {
|
||||||
|
if _, _, ok := seatCompanionLoadout(sess, uid); !ok {
|
||||||
|
return refundSpellSlot(uid, slotLevel)
|
||||||
|
}
|
||||||
|
used := companionSlotsForRun(sess.RunID)
|
||||||
|
if slotLevel < 1 || slotLevel >= len(used) || used[slotLevel] <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
used[slotLevel]--
|
||||||
|
return setCompanionSlotsForRun(sess.RunID, used)
|
||||||
|
}
|
||||||
277
internal/plugin/combat_seat_spells_test.go
Normal file
277
internal/plugin/combat_seat_spells_test.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The hired companion casts.
|
||||||
|
//
|
||||||
|
// He could not, and the reason was one line: every spell lookup in the engine is
|
||||||
|
// keyed on a user id and answered by a dnd_* table, and he has no rows in any of
|
||||||
|
// them — deliberately, because a sheet on disk is what would turn him into a real
|
||||||
|
// character everywhere. So the picker's first statement (`LoadDnDCharacter(uid)`)
|
||||||
|
// came back nil and returned "attack", every turn, for the whole fight.
|
||||||
|
//
|
||||||
|
// Role-fill hands a lone martial a Cleric. So the common case of the feature was a
|
||||||
|
// healer who could not heal, in a party engine that had only just learned to let
|
||||||
|
// anyone heal anyone (§1). These pin both halves: that he picks the heal, and that
|
||||||
|
// picking it leaves no trace of him in the database.
|
||||||
|
|
||||||
|
// hireForFight seeds an expedition with the companion hired into it, and returns
|
||||||
|
// the run id his loadout is resolved against.
|
||||||
|
func hireForFight(t *testing.T, expID string, owner id.UserID, class DnDClass, level int) string {
|
||||||
|
t.Helper()
|
||||||
|
seedExpedition(t, expID, owner, "active")
|
||||||
|
seatLeaderFixture(t, expID)
|
||||||
|
if err := hireCompanion(expID, class, level); err != nil {
|
||||||
|
t.Fatalf("hireCompanion: %v", err)
|
||||||
|
}
|
||||||
|
return "run-" + expID
|
||||||
|
}
|
||||||
|
|
||||||
|
// petePartyFight seats a hurt leader and the companion, and returns the turn.
|
||||||
|
func petePartyFight(t *testing.T, p *AdventurePlugin, runID string, leader id.UserID, leaderHP int) *combatTurn {
|
||||||
|
t.Helper()
|
||||||
|
monster := dndBestiary["goblin"]
|
||||||
|
lead, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
|
||||||
|
lead.Name = "lead"
|
||||||
|
pete, _, _ := p.companionCombatant(ClassCleric, 6, monster, 2, 0)
|
||||||
|
pete.Name = companionDisplayName
|
||||||
|
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
|
||||||
|
|
||||||
|
sess, err := p.startPartyCombatSession(runID, "enc", "goblin", &enemy, []CombatSeatSetup{
|
||||||
|
{UserID: leader, HP: leaderHP, HPMax: 100, Mods: lead.Mods, C: &lead},
|
||||||
|
{UserID: companionUserID(), HP: 60, HPMax: 60, Mods: pete.Mods, C: &pete, EngineDriven: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &combatTurn{
|
||||||
|
sess: sess, players: []*Combatant{&lead, &pete}, enemy: &enemy,
|
||||||
|
seat: 1, uid: companionUserID(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The headline: a hired Cleric, watching the leader bleed out, casts a heal on him.
|
||||||
|
// Before the seat-scoped spellbook this returned ("attack", "") — he swung a mace
|
||||||
|
// at the boss while the man who paid for him died.
|
||||||
|
func TestCompanionSpells_HiredClericHealsTheLeader(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-heal", leader, ClassCleric, 6)
|
||||||
|
ct := petePartyFight(t, p, runID, leader, 20) // leader at 20/100 — well under the 45% bar
|
||||||
|
|
||||||
|
kind, arg := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
|
||||||
|
if kind != "cast" {
|
||||||
|
t.Fatalf("the hired cleric picked %q %q with the leader at 20%% HP — he must heal", kind, arg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(arg, "@lead") {
|
||||||
|
t.Fatalf("cast arg = %q, want the heal aimed at the leader's seat", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it lands on the leader, not on himself.
|
||||||
|
action, settle, msg := p.castActionForSeat(ct, 1, arg)
|
||||||
|
if msg != "" {
|
||||||
|
t.Fatalf("castActionForSeat refused the companion's own pick: %s", msg)
|
||||||
|
}
|
||||||
|
settle(true)
|
||||||
|
eff := action.Effect
|
||||||
|
if eff == nil || eff.AllyHeal <= 0 {
|
||||||
|
t.Fatalf("effect = %+v, want an ally heal", eff)
|
||||||
|
}
|
||||||
|
if eff.AllySeat != 0 {
|
||||||
|
t.Errorf("heal landed on seat %d, want seat 0 (the leader)", eff.AllySeat)
|
||||||
|
}
|
||||||
|
if eff.PlayerHeal != 0 {
|
||||||
|
t.Errorf("the heal also healed the caster (%d HP) — it was redirected, not copied", eff.PlayerHeal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// His slots are spent on his seat, and he leaves no rows behind.
|
||||||
|
//
|
||||||
|
// This is the invariant with teeth. castActionForSeat used to load the caster via
|
||||||
|
// ensureCharForDnDCmd, whose auto-migration branch — handed a user with no sheet —
|
||||||
|
// *builds one at level 1 and saves it*. Pointed at the companion that silently
|
||||||
|
// makes him a player: a dnd_character row, a player_meta seed, a spellbook, and a
|
||||||
|
// level-1 chassis in place of the level he was hired at.
|
||||||
|
func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-rows", leader, ClassCleric, 6)
|
||||||
|
ct := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
|
||||||
|
action, settle, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead")
|
||||||
|
if msg != "" {
|
||||||
|
t.Fatalf("the hired cleric could not cast cure wounds: %s", msg)
|
||||||
|
}
|
||||||
|
settle(true)
|
||||||
|
if action.Effect == nil || action.Effect.AllyHeal <= 0 {
|
||||||
|
t.Fatalf("effect = %+v, want an ally heal", action.Effect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slot came off the expedition's ledger — which is where it has to live, so
|
||||||
|
// that it is still spent in the NEXT fight of the same run.
|
||||||
|
if used := companionSlotsForRun(ct.sess.RunID); used[1] != 1 {
|
||||||
|
t.Errorf("companion spent %v level-1 slots on the run's ledger, want 1", used[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range []string{"dnd_character", "dnd_known_spells", "dnd_spell_slots", "player_meta"} {
|
||||||
|
var n int
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM `+table+` WHERE user_id = ?`, string(companionUserID()),
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count %s: %v", table, err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("casting wrote %d %s row(s) for the companion — he is not a player", n, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// He runs dry like anybody else, and then he swings. A companion with an infinite
|
||||||
|
// spell pool is the "carry" the whole design says he must never be.
|
||||||
|
func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6)
|
||||||
|
ct := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
|
||||||
|
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
|
||||||
|
if total <= 0 {
|
||||||
|
t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve")
|
||||||
|
}
|
||||||
|
for i := range total {
|
||||||
|
if ok, err := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil || !ok {
|
||||||
|
t.Fatalf("slot %d/%d: consume = %v (%v), want it to succeed", i+1, total, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); ok {
|
||||||
|
t.Fatalf("the companion cast a %d-th level-1 spell out of a %d-slot pool", total+1, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refund half: a round that fails to resolve gives the slot back.
|
||||||
|
if err := refundSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); !ok {
|
||||||
|
t.Error("a refunded slot was not castable again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// His pool does NOT refill between fights, and it DOES come back at camp.
|
||||||
|
//
|
||||||
|
// This is the one the sweep caught and the unit tests did not. The first cut of
|
||||||
|
// the spellbook parked his ledger on his combat seat — and a seat is per-session,
|
||||||
|
// so every fight opened a fresh one and he walked in with full slots. A human
|
||||||
|
// cleric rations a single pool across the whole run; rationing it IS the caster's
|
||||||
|
// game. Handed an infinite pool, a gearless level-penalized hireling out-cleared a
|
||||||
|
// same-level human cleric by 15pp in the sim.
|
||||||
|
func TestCompanionSpells_PoolIsRationedAcrossTheRun(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-ration", leader, ClassCleric, 6)
|
||||||
|
|
||||||
|
// Fight one: spend every level-1 slot he owns.
|
||||||
|
first := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
|
||||||
|
for range total {
|
||||||
|
if ok, err := consumeSeatSlot(first.sess, 1, companionUserID(), 1); err != nil || !ok {
|
||||||
|
t.Fatalf("consume: %v (%v)", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fight two, same run — a NEW combat session, which is exactly what used to
|
||||||
|
// hand him a fresh pool.
|
||||||
|
if err := markCombatSessionExpired(first.sess.SessionID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); ok {
|
||||||
|
t.Error("the companion's spell slots refilled between fights — he is an infinite caster")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and camp gives them back, the same way it does for every human.
|
||||||
|
if err := refreshCompanionSlots("exp-ration"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); !ok {
|
||||||
|
t.Error("camp did not restore the companion's slots — his pool only ever goes down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// He carries his wounds between fights, and camp patches him up.
|
||||||
|
//
|
||||||
|
// He used to re-seat at full max HP for every single fight — "he arrives fresh
|
||||||
|
// next time" was the close-out's stated intent — which is an infinite body. A
|
||||||
|
// player bleeds across a 30-room run and only heals at camp; the hireling soaked
|
||||||
|
// his share of every fight and then reset. In the sim his party fled 5 runs out of
|
||||||
|
// 640 where the same party with a *human* cleric fled 56.
|
||||||
|
func TestCompanion_CarriesWoundsBetweenFights(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
seedExpedition(t, "exp-hp", leader, "active")
|
||||||
|
seatLeaderFixture(t, "exp-hp")
|
||||||
|
if err := hireCompanion("exp-hp", ClassCleric, 6); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh hire is at full.
|
||||||
|
if got := companionSeatHP("exp-hp", 100); got != 100 {
|
||||||
|
t.Errorf("a fresh hire seats at %d/100 HP, want full", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// He walks out of a fight on 30 HP; he walks into the next one on 30 HP.
|
||||||
|
if err := setCompanionHP("exp-hp", 30); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := companionSeatHP("exp-hp", 100); got != 30 {
|
||||||
|
t.Errorf("he re-seated at %d/100 HP after ending a fight on 30 — the wound did not carry", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropped in a fight, he comes back on his feet but barely — not as a corpse
|
||||||
|
// (there is no companion-death rule) and not at full.
|
||||||
|
if err := setCompanionHP("exp-hp", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := companionSeatHP("exp-hp", 100); got != 1 {
|
||||||
|
t.Errorf("after being dropped he seats at %d/100 HP, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camp puts him right, exactly as it does every human.
|
||||||
|
if err := refreshCompanionHP("exp-hp"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := companionSeatHP("exp-hp", 100); got != 100 {
|
||||||
|
t.Errorf("camp left him on %d/100 HP — his body only ever goes down", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hired martial is still a martial: the spellbook is the class's, not a blanket
|
||||||
|
// grant, so hiring a Fighter does not quietly buy a caster.
|
||||||
|
func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-mart", leader, ClassFighter, 6)
|
||||||
|
ct := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
|
||||||
|
if known, _ := seatKnownSpells(ct.sess, 1, companionUserID()); len(known) != 0 {
|
||||||
|
t.Errorf("a hired Fighter knows %d spells, want none", len(known))
|
||||||
|
}
|
||||||
|
if _, _, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead"); msg == "" {
|
||||||
|
t.Error("a hired Fighter cast cure wounds")
|
||||||
|
}
|
||||||
|
kind, _ := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
|
||||||
|
if kind != "attack" {
|
||||||
|
t.Errorf("a hired Fighter picked %q, want attack", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
internal/plugin/combat_seat_weight_test.go
Normal file
108
internal/plugin/combat_seat_weight_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// §2(a) — the enemy charges for what a seat BRINGS, not for the fact that it sat
|
||||||
|
// down.
|
||||||
|
//
|
||||||
|
// Both scaling levers (the +15% boss HP and the 1 → 2.4 attacks-a-round action
|
||||||
|
// economy) counted seats. A seat count charges the same for an under-levelled
|
||||||
|
// friend, a hired NPC, and a true peer — so a below-median body cost a full seat's
|
||||||
|
// worth of boss and did not give a full seat's worth back. Measured: hiring the
|
||||||
|
// companion was *worse than going alone* (66.1% against solo's 69.0%) once his
|
||||||
|
// free full-heal was taken away.
|
||||||
|
//
|
||||||
|
// The invariant that makes this safe to ship: every seat that IS a peer still
|
||||||
|
// weighs exactly 1.0, so solo and a party of equals are byte-identical and the
|
||||||
|
// balance corpus does not move. Only an unequal roster lands between the knots.
|
||||||
|
|
||||||
|
func TestSeatWeight_APeerWeighsExactlyOne(t *testing.T) {
|
||||||
|
// The leader, and a friend of the leader's level: both full price.
|
||||||
|
if got := seatWeight(10, 10, false); got != 1.0 {
|
||||||
|
t.Errorf("a peer weighs %v, want exactly 1.0 — a party of equals must not move", got)
|
||||||
|
}
|
||||||
|
// Out-levelling the leader does not make the boss harder for everybody else.
|
||||||
|
if got := seatWeight(14, 10, false); got != 1.0 {
|
||||||
|
t.Errorf("a higher-level friend weighs %v, want 1.0 (capped)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeatWeight_TheUnderLevelledAndTheHiredCostLess(t *testing.T) {
|
||||||
|
// The under-levelled friend — the case that has nothing to do with the
|
||||||
|
// companion and has been live since parties shipped.
|
||||||
|
half := seatWeight(5, 10, false)
|
||||||
|
if half >= 1.0 || half <= seatWeightFloor {
|
||||||
|
t.Errorf("a level-5 friend of a level-10 leader weighs %v, want between the floor and 1.0", half)
|
||||||
|
}
|
||||||
|
// The hireling pays the same level penalty AND the discount for everything a
|
||||||
|
// player accrues that he never will (subclass, magic items, Masterwork gear).
|
||||||
|
hired := seatWeight(9, 10, true)
|
||||||
|
peerAtSameLevel := seatWeight(9, 10, false)
|
||||||
|
if hired >= peerAtSameLevel {
|
||||||
|
t.Errorf("the hireling weighs %v and a human of his level weighs %v — he must cost the enemy less",
|
||||||
|
hired, peerAtSameLevel)
|
||||||
|
}
|
||||||
|
// But a body is never free: it is still one more thing the boss has to kill.
|
||||||
|
if got := seatWeight(1, 20, true); got < seatWeightFloor {
|
||||||
|
t.Errorf("a hopelessly under-levelled hireling weighs %v, below the floor %v", got, seatWeightFloor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The levers themselves: integer knots byte-exact, fractions in between.
|
||||||
|
func TestSeatWeight_ScalingIsExactAtThePeerKnots(t *testing.T) {
|
||||||
|
// Solo and a party of peers reproduce the pre-§2(a) numbers exactly. This is
|
||||||
|
// the whole safety argument — if either of these drifts, the corpus is invalid.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
weight float64
|
||||||
|
acts float64
|
||||||
|
hp float64
|
||||||
|
}{
|
||||||
|
{1, 1, 1.0}, // solo
|
||||||
|
{2, 2.4, 1.15}, // a duo of peers
|
||||||
|
{3, 5, 1.15}, // a trio of peers
|
||||||
|
} {
|
||||||
|
if got := partyActionExpectation(tc.weight); got != tc.acts {
|
||||||
|
t.Errorf("weight %v: enemy actions = %v, want exactly %v", tc.weight, got, tc.acts)
|
||||||
|
}
|
||||||
|
if got := partyEnemyHPScale(tc.weight); got != tc.hp {
|
||||||
|
t.Errorf("weight %v: enemy HP scale = %v, want exactly %v", tc.weight, got, tc.hp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A leader plus a hireling is strictly cheaper than a leader plus a peer, and
|
||||||
|
// strictly dearer than soloing. Bringing him must cost the boss *something* —
|
||||||
|
// that is what stops a free body from becoming a carry.
|
||||||
|
duoWithHire := 1 + seatWeight(9, 10, true)
|
||||||
|
if a := partyActionExpectation(duoWithHire); a <= 1 || a >= 2.4 {
|
||||||
|
t.Errorf("leader + hireling buys the enemy %v actions, want strictly between 1 and 2.4", a)
|
||||||
|
}
|
||||||
|
if h := partyEnemyHPScale(duoWithHire); h <= 1.0 || h >= 1.15 {
|
||||||
|
t.Errorf("leader + hireling scales boss HP by %v, want strictly between 1.0 and 1.15", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seat that is DOWN buys the enemy nothing. §2(b) fixed the head-count half of
|
||||||
|
// this; the weight half has to hold too, or a corpse keeps paying for swings.
|
||||||
|
func TestSeatWeight_TheDeadBuyTheEnemyNothing(t *testing.T) {
|
||||||
|
alive := &Combatant{SeatWeight: 1}
|
||||||
|
hire := &Combatant{SeatWeight: 0.6}
|
||||||
|
st := &combatState{actors: []*actor{
|
||||||
|
{c: alive, playerHP: 40},
|
||||||
|
{c: hire, playerHP: 0}, // dropped
|
||||||
|
}}
|
||||||
|
if got := livingWeight(st); got != 1 {
|
||||||
|
t.Errorf("living weight with a downed hireling = %v, want 1 (only the survivor pays)", got)
|
||||||
|
}
|
||||||
|
st.actors[1].playerHP = 20
|
||||||
|
if got := livingWeight(st); got != 1.6 {
|
||||||
|
t.Errorf("living weight with the hireling up = %v, want 1.6", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unset weight reads as a full peer, so every combatant built before this
|
||||||
|
// existed — and every test that builds one by hand — is priced exactly as before.
|
||||||
|
func TestSeatWeight_UnsetIsAPeer(t *testing.T) {
|
||||||
|
if got := combatantWeight(&Combatant{}); got != 1 {
|
||||||
|
t.Errorf("a combatant with no weight set counts %v, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ type ActorStatuses struct {
|
|||||||
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
|
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
|
||||||
// Without the latch an away member taxes the party the full deadline every
|
// Without the latch an away member taxes the party the full deadline every
|
||||||
// single round; with it, they cost one wait and then resolve instantly. Any
|
// single round; with it, they cost one wait and then resolve instantly. Any
|
||||||
// combat command from that member clears it.
|
// combat command from that member clears it — they typed, so they are back.
|
||||||
//
|
//
|
||||||
// Like the Buff* deltas it is session-layer state with no combatState
|
// Like the Buff* deltas it is session-layer state with no combatState
|
||||||
// counterpart, so snapshotActor carries it over from the prior snapshot
|
// counterpart, so snapshotActor carries it over from the prior snapshot
|
||||||
@@ -129,6 +129,24 @@ type ActorStatuses struct {
|
|||||||
// fight has no turn deadline, only the 1h session reaper.
|
// fight has no turn deadline, only the 1h session reaper.
|
||||||
Autopilot bool `json:"autopilot,omitempty"`
|
Autopilot bool `json:"autopilot,omitempty"`
|
||||||
|
|
||||||
|
// EngineDriven marks a seat that has NO human behind it and never will: a
|
||||||
|
// hired companion today, an NPC ally or a Pete-led expedition tomorrow. It is
|
||||||
|
// the answer to "who owns this turn", and it is deliberately NOT the same
|
||||||
|
// question as Autopilot.
|
||||||
|
//
|
||||||
|
// Autopilot means "a human is away"; it is provisional, and a keystroke ends
|
||||||
|
// it. EngineDriven means "there is nobody to come back", and nothing clears it.
|
||||||
|
// Collapsing the two is a bug with teeth: the expedition autopilot drives a
|
||||||
|
// party by dispatching each seat's turn as a command from that seat, so an
|
||||||
|
// engine seat's own auto-played move arrived back at beginCombatTurn looking
|
||||||
|
// exactly like a player returning to the keyboard, and cleared the very latch
|
||||||
|
// that was moving it. The seat then stood in the fight doing nothing for the
|
||||||
|
// rest of the fight — while the enemy it had inflated by 15% killed the party.
|
||||||
|
//
|
||||||
|
// So the property lives on the seat, not on an identity check, and no command
|
||||||
|
// path can unset it.
|
||||||
|
EngineDriven bool `json:"engine_driven,omitempty"`
|
||||||
|
|
||||||
// Debuffs the enemy has stacked onto this character specifically.
|
// Debuffs the enemy has stacked onto this character specifically.
|
||||||
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||||
@@ -256,7 +274,8 @@ func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
|||||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||||
st.HealChargesLeft = playerMods.HealItemCharges
|
st.HealChargesLeft = playerMods.HealItemCharges
|
||||||
st.ArmedAbility = seat.ArmedAbility
|
st.ArmedAbility = seat.ArmedAbility
|
||||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
st.EngineDriven = seat.EngineDriven
|
||||||
|
return st.EngineDriven || st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
||||||
st.ArmedAbility != ""
|
st.ArmedAbility != ""
|
||||||
}
|
}
|
||||||
@@ -387,16 +406,30 @@ func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
|
|||||||
// turn silently rather than blocking the round on a corpse.
|
// turn silently rather than blocking the round on a corpse.
|
||||||
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
||||||
|
|
||||||
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
|
// seatIsEngineDriven reports whether a seat has no human behind it at all — a
|
||||||
// by a lapsed turn deadline.
|
// hired companion, an NPC ally. Permanent for the life of the fight; no command
|
||||||
|
// clears it. Contrast seatIsAutopiloted, which is a human who stepped away.
|
||||||
|
func (s *CombatSession) seatIsEngineDriven(seat int) bool {
|
||||||
|
return s.actorStatusesForSeat(seat).EngineDriven
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatIsAutopiloted reports whether the engine, rather than a person, decides
|
||||||
|
// this seat's action — because its human is away (a lapsed turn deadline) or
|
||||||
|
// because it never had one.
|
||||||
|
//
|
||||||
|
// Every driver in the codebase reads this, which is exactly why the two reasons
|
||||||
|
// share one accessor: the picker does not care WHY nobody is typing. What differs
|
||||||
|
// is who is allowed to take the wheel back, and that question is asked in
|
||||||
|
// beginCombatTurn against seatIsEngineDriven — never here.
|
||||||
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
|
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
|
||||||
return s.actorStatusesForSeat(seat).Autopilot
|
st := s.actorStatusesForSeat(seat)
|
||||||
|
return st.Autopilot || st.EngineDriven
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
|
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
|
||||||
// waiting on its player: it is down (forfeits silently) or latched onto
|
// waiting on its player: it is down (forfeits silently), latched onto autopilot,
|
||||||
// autopilot. driveCombatRound keeps stepping while this holds, so a round only
|
// or has no human to wait for. driveCombatRound keeps stepping while this holds,
|
||||||
// comes to rest on a live human's turn.
|
// so a round only comes to rest on a live human's turn.
|
||||||
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
||||||
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
||||||
}
|
}
|
||||||
@@ -491,7 +524,7 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
|||||||
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
||||||
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
||||||
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
||||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
|
||||||
owner := seats[0]
|
owner := seats[0]
|
||||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||||
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
||||||
@@ -553,6 +586,12 @@ type CombatSeatSetup struct {
|
|||||||
// if they armed nothing. It is persisted onto the seat's statuses so every
|
// if they armed nothing. It is persisted onto the seat's statuses so every
|
||||||
// later rebuild can re-apply the ability without re-spending it.
|
// later rebuild can re-apply the ability without re-spending it.
|
||||||
ArmedAbility string
|
ArmedAbility string
|
||||||
|
// EngineDriven seats this combatant with no human behind it — the hired
|
||||||
|
// companion today. It resolves from the opening round rather than after the
|
||||||
|
// 3-minute away-player deadline (nobody is coming, so waiting out a deadline
|
||||||
|
// would idle the fight and then announce him to the party as absent), and no
|
||||||
|
// command can hand the wheel back to a player who does not exist.
|
||||||
|
EngineDriven bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||||
|
|||||||
@@ -154,13 +154,36 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
|||||||
|
|
||||||
seats := sess.SeatUserIDs()
|
seats := sess.SeatUserIDs()
|
||||||
players := make([]*Combatant, len(seats))
|
players := make([]*Combatant, len(seats))
|
||||||
|
levels := make([]int, len(seats))
|
||||||
|
companions := make([]bool, len(seats))
|
||||||
var enemy Combatant
|
var enemy Combatant
|
||||||
for seat, uid := range seats {
|
for seat, uid := range seats {
|
||||||
// The ability this seat armed was consumed once, at fight start, and its
|
// The ability this seat armed was consumed once, at fight start, and its
|
||||||
// id parked on their statuses. Re-applying it here — not re-consuming —
|
// id parked on their statuses. Re-applying it here — not re-consuming —
|
||||||
// is what makes a rage last the whole fight instead of one round.
|
// is what makes a rage last the whole fight instead of one round.
|
||||||
st := sess.actorStatusesForSeat(seat)
|
st := sess.actorStatusesForSeat(seat)
|
||||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
|
||||||
|
// The hired companion has no sheet to load — by design, because a
|
||||||
|
// player_meta row for him is the thing that would turn him into a real
|
||||||
|
// character everywhere. Synthesize his seat instead. This branch is
|
||||||
|
// load-bearing: buildZoneCombatants would fail on him, and one
|
||||||
|
// unbuildable seat fails the whole rebuild, which is what every human's
|
||||||
|
// !attack in the fight depends on.
|
||||||
|
if isCompanionUser(uid) {
|
||||||
|
class, level := companionLoadoutForRun(sess.RunID)
|
||||||
|
player, en, _ := p.companionCombatant(class, level, monster, int(zone.Tier), run.DMMood)
|
||||||
|
applySessionBuffs(&player, st)
|
||||||
|
players[seat] = &player
|
||||||
|
levels[seat], companions[seat] = level, true
|
||||||
|
if seat == 0 {
|
||||||
|
// Unreachable today (seat 0 is always the leader), but if it ever
|
||||||
|
// isn't, the enemy still has to come from somewhere.
|
||||||
|
enemy = en
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
player, e, dc, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||||
}
|
}
|
||||||
@@ -171,22 +194,47 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
|||||||
// stat deltas are applied here — and only that seat's own.
|
// stat deltas are applied here — and only that seat's own.
|
||||||
applySessionBuffs(&player, st)
|
applySessionBuffs(&player, st)
|
||||||
players[seat] = &player
|
players[seat] = &player
|
||||||
|
if dc != nil {
|
||||||
|
levels[seat] = dc.Level
|
||||||
|
}
|
||||||
if seat == 0 {
|
if seat == 0 {
|
||||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||||
// Only the *player* half of the build varies by seat.
|
// Only the *player* half of the build varies by seat.
|
||||||
enemy = e
|
enemy = e
|
||||||
// Party-only enemy HP bump, re-derived each turn from the template so
|
|
||||||
// it never compounds. Matches the scalar startPartyCombatSession used
|
|
||||||
// for the initial persist; solo (roster 1) scales by 1.0.
|
|
||||||
if sess.IsParty() {
|
|
||||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What each seat costs the enemy, priced against the leader. This runs after
|
||||||
|
// the loop and not inside it, because a seat's weight is relative to seat 0's
|
||||||
|
// level and the enemy's HP is scaled off the *summed* weight — neither is known
|
||||||
|
// until every seat is built.
|
||||||
|
applySeatWeights(players, levels, companions)
|
||||||
|
|
||||||
|
// Party-only enemy HP bump, re-derived each turn from the template so it never
|
||||||
|
// compounds. Matches the scalar startPartyCombatSession used for the initial
|
||||||
|
// persist; solo (one seat, weight 1) scales by 1.0.
|
||||||
|
if sess.IsParty() {
|
||||||
|
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeight(players))
|
||||||
|
}
|
||||||
return players, &enemy, nil
|
return players, &enemy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySeatWeights prices every seat against the leader's level. Seat 0 is the
|
||||||
|
// leader and always weighs a full 1.0.
|
||||||
|
func applySeatWeights(players []*Combatant, levels []int, companions []bool) {
|
||||||
|
if len(players) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
leaderLevel := levels[0]
|
||||||
|
for i, c := range players {
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.SeatWeight = seatWeight(levels[i], leaderLevel, companions[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
|
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
|
||||||
// needs: the Berserker's rage flag, which decides whether the character owes a
|
// needs: the Berserker's rage flag, which decides whether the character owes a
|
||||||
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ type turnActionEffect struct {
|
|||||||
EnemyDamage int
|
EnemyDamage int
|
||||||
PlayerHeal int
|
PlayerHeal int
|
||||||
EnemySkip bool // control spell: enemy forfeits its attack this round
|
EnemySkip bool // control spell: enemy forfeits its attack this round
|
||||||
|
|
||||||
|
// AllyHeal heals ANOTHER seat instead of the caster — the thing the engine
|
||||||
|
// could not do until §1, and the reason a party cleric was a cleric in name
|
||||||
|
// only. Every heal in the engine wrote to the acting seat: MistyHealProc,
|
||||||
|
// HealItem, PlayerHeal above. Nothing could put a hit point on a friend, so
|
||||||
|
// the class whose entire identity is keeping other people alive could not.
|
||||||
|
// N3 shipped that way and no test noticed, because there was no party golden.
|
||||||
|
//
|
||||||
|
// Zero means no ally heal, which keeps every existing construction of this
|
||||||
|
// struct meaning exactly what it meant before. AllySeat is only read when
|
||||||
|
// AllyHeal > 0, so its zero value is never mistaken for "seat 0".
|
||||||
|
AllyHeal int
|
||||||
|
AllySeat int
|
||||||
// ConcentrationDmg arms a per-round aura tick when a concentration damage
|
// ConcentrationDmg arms a per-round aura tick when a concentration damage
|
||||||
// spell is cast: EnemyDamage is the burst that lands this round, this is
|
// spell is cast: EnemyDamage is the burst that lands this round, this is
|
||||||
// what re-ticks at every round_end after. Zero for one-shot spells; a
|
// what re-ticks at every round_end after. Zero for one-shot spells; a
|
||||||
@@ -560,6 +573,25 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||||
}
|
}
|
||||||
|
// §1 — heal somebody else. The caster's cursor stays where it is; only the
|
||||||
|
// target's HP moves.
|
||||||
|
//
|
||||||
|
// A downed seat is NOT raised. Death in this engine is terminal for the fight
|
||||||
|
// (the close-out marks them, the hospital takes them), and a heal that
|
||||||
|
// resurrected a corpse would quietly rewrite the loss rules every close-out
|
||||||
|
// path depends on. Healing keeps people up; it does not bring them back.
|
||||||
|
if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) {
|
||||||
|
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
|
||||||
|
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
|
||||||
|
before := tgt.playerHP
|
||||||
|
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
|
||||||
|
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
Seat: eff.AllySeat, Desc: eff.Label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
// Arm / replace the concentration aura. A new concentration cast overwrites
|
// Arm / replace the concentration aura. A new concentration cast overwrites
|
||||||
// the old one (5e: one concentration at a time); non-concentration casts
|
// the old one (5e: one concentration at a time); non-concentration casts
|
||||||
// leave any running aura alone.
|
// leave any running aura alone.
|
||||||
|
|||||||
@@ -332,8 +332,22 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sqlExecer is the subset of *sql.DB / *sql.Tx that saveDnDCharacterExec needs,
|
||||||
|
// so the upsert can run either standalone or inside a caller's transaction.
|
||||||
|
type sqlExecer interface {
|
||||||
|
Exec(query string, args ...any) (sql.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
// SaveDnDCharacter upserts the row.
|
// SaveDnDCharacter upserts the row.
|
||||||
func SaveDnDCharacter(c *DnDCharacter) error {
|
func SaveDnDCharacter(c *DnDCharacter) error {
|
||||||
|
return saveDnDCharacterExec(db.Get(), c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveDnDCharacterExec runs the dnd_character upsert against any executor. Used
|
||||||
|
// directly by SaveDnDCharacter and inside grantDnDXP's overflow→renown
|
||||||
|
// transaction (adventure_renown.go), so the character save and the renown
|
||||||
|
// credit commit atomically.
|
||||||
|
func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
|
||||||
pending := 0
|
pending := 0
|
||||||
if c.PendingSetup {
|
if c.PendingSetup {
|
||||||
pending = 1
|
pending = 1
|
||||||
@@ -346,7 +360,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
|||||||
if c.OnboardingSent {
|
if c.OnboardingSent {
|
||||||
onboard = 1
|
onboard = 1
|
||||||
}
|
}
|
||||||
_, err := db.Get().Exec(`
|
_, err := ex.Exec(`
|
||||||
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
|
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
|
||||||
str_score, dex_score, con_score, int_score, wis_score, cha_score,
|
str_score, dex_score, con_score, int_score, wis_score, cha_score,
|
||||||
hp_current, hp_max, temp_hp, armor_class,
|
hp_current, hp_max, temp_hp, armor_class,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -27,9 +29,11 @@ import (
|
|||||||
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
||||||
// a "Phase 11" note — they require the boss turn-based engine.
|
// a "Phase 11" note — they require the boss turn-based engine.
|
||||||
//
|
//
|
||||||
// Cross-player targeting (--target @user) is deliberately deferred. SP2
|
// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human
|
||||||
// supports self-target only. Heals on self work; ally buffs queue with the
|
// on your expedition — see dnd_cast_target.go. Only heals may name somebody else
|
||||||
// caster as the target until SP3 wires up the multi-player resolution.
|
// out of combat: UTILITY resolves on the caster, and everything else queues as a
|
||||||
|
// PendingCast for the *caster's* next fight, where an ally target has nothing to
|
||||||
|
// mean. In-combat targeting is splitCastTarget (combat_cmd.go).
|
||||||
|
|
||||||
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
|
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
|
||||||
// Keep this minimal — the combat layer resolves the actual numbers on fire.
|
// Keep this minimal — the combat layer resolves the actual numbers on fire.
|
||||||
@@ -97,6 +101,13 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
return p.dndCastDrop(ctx, c)
|
return p.dndCastDrop(ctx, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An ally target — `--target @alex`, or a trailing `@alex` — comes off the
|
||||||
|
// string before the spell parser sees it, exactly as in combat. It is
|
||||||
|
// resolved (and refused) further down, once we know the spell is a heal: a
|
||||||
|
// target on anything else is a mistake, and refusing it early would cost us
|
||||||
|
// the "which spell?" half of the error message.
|
||||||
|
args, targetName := splitOutOfCombatTarget(args)
|
||||||
|
|
||||||
// Parse spell name + optional --upcast N.
|
// Parse spell name + optional --upcast N.
|
||||||
tokens := strings.Fields(args)
|
tokens := strings.Fields(args)
|
||||||
upcast := 0
|
upcast := 0
|
||||||
@@ -111,11 +122,6 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
case "--target":
|
|
||||||
// Reserved for SP3 — accept and ignore for now.
|
|
||||||
if i+1 < len(tokens) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
spellTokens = append(spellTokens, tokens[i])
|
spellTokens = append(spellTokens, tokens[i])
|
||||||
}
|
}
|
||||||
@@ -195,6 +201,23 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve an ally target now — BEFORE anything is spent. Out of combat only a
|
||||||
|
// heal can land on somebody else: UTILITY resolves on the caster and
|
||||||
|
// everything else queues as a PendingCast for *this* caster's next fight, so
|
||||||
|
// a target on those would be accepted and then silently dropped.
|
||||||
|
var targetUser id.UserID
|
||||||
|
if targetName != "" {
|
||||||
|
if spell.Effect != EffectSpellHeal {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"%s isn't a healing spell — outside a fight you can only cast those on someone else. Drop the target to cast it on yourself.", spell.Name))
|
||||||
|
}
|
||||||
|
uid, errMsg := resolveCastTargetOnExpedition(ctx.Sender, targetName)
|
||||||
|
if errMsg != "" {
|
||||||
|
return p.SendDM(ctx.Sender, errMsg)
|
||||||
|
}
|
||||||
|
targetUser = uid // empty when they named themselves — self-heal as usual
|
||||||
|
}
|
||||||
|
|
||||||
// Material cost (Revivify, Raise Dead).
|
// Material cost (Revivify, Raise Dead).
|
||||||
if spell.MaterialCost > 0 {
|
if spell.MaterialCost > 0 {
|
||||||
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||||
@@ -220,6 +243,9 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
// debited above; if SaveDnDCharacter fails we refund.
|
// debited above; if SaveDnDCharacter fails we refund.
|
||||||
switch spell.Effect {
|
switch spell.Effect {
|
||||||
case EffectSpellHeal:
|
case EffectSpellHeal:
|
||||||
|
if targetUser != "" {
|
||||||
|
return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel)
|
||||||
|
}
|
||||||
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
||||||
case EffectUtility:
|
case EffectUtility:
|
||||||
return p.resolveUtility(ctx, c, spell, slotLevel)
|
return p.resolveUtility(ctx, c, spell, slotLevel)
|
||||||
@@ -231,7 +257,10 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
|
|
||||||
// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
|
// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
// rollOutOfCombatHeal is what the spell is worth in the caster's hands. It is a
|
||||||
|
// property of the caster (their WIS, their domain), never of the body it lands
|
||||||
|
// on, so an ally heal rolls exactly the same as a self-heal.
|
||||||
|
func rollOutOfCombatHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
|
||||||
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
||||||
if dice == 0 {
|
if dice == 0 {
|
||||||
dice, faces = 1, 8 // safety fallback
|
dice, faces = 1, 8 // safety fallback
|
||||||
@@ -253,6 +282,11 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
|||||||
}
|
}
|
||||||
heal += abilityModifier(c.WIS)
|
heal += abilityModifier(c.WIS)
|
||||||
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
||||||
|
return heal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||||
|
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||||||
|
|
||||||
before := c.HPCurrent
|
before := c.HPCurrent
|
||||||
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
||||||
@@ -272,6 +306,51 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
|||||||
renderSlotsBrief(ctx.Sender)))
|
renderSlotsBrief(ctx.Sender)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveAllyHealOutOfCombat puts the heal on somebody else's sheet. The slot is
|
||||||
|
// already spent, so every failure below refunds it: a heal that lands on nobody
|
||||||
|
// must not cost the caster anything.
|
||||||
|
func (p *AdventurePlugin) resolveAllyHealOutOfCombat(ctx MessageContext, c *DnDCharacter, target id.UserID, spell SpellDefinition, slotLevel int) error {
|
||||||
|
refund := func(msg string) error {
|
||||||
|
if spell.Level > 0 {
|
||||||
|
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||||||
|
before, after, maxHP, err := healPartyMember(target, heal)
|
||||||
|
|
||||||
|
targetName := charName(target)
|
||||||
|
if targetName == "" {
|
||||||
|
targetName = target.Localpart()
|
||||||
|
}
|
||||||
|
casterName := charName(ctx.Sender)
|
||||||
|
if casterName == "" {
|
||||||
|
casterName = ctx.Sender.Localpart()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errHealTargetFull):
|
||||||
|
return refund(fmt.Sprintf("**%s** is already at full health (%d/%d). Slot kept.", targetName, before, maxHP))
|
||||||
|
case errors.Is(err, errHealTargetDown):
|
||||||
|
// The same rule the combat path holds: a heal is not a resurrection.
|
||||||
|
return refund(fmt.Sprintf("**%s** is down — a heal won't bring them back. Slot kept.", targetName))
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return refund(fmt.Sprintf("**%s** hasn't run `!setup` yet, so there's no sheet to heal.", targetName))
|
||||||
|
case err != nil:
|
||||||
|
return refund("Couldn't apply the heal: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = p.SendDM(target, fmt.Sprintf(
|
||||||
|
"🩹 **%s** casts **%s** on you — %d HP back (%d → %d / %d).",
|
||||||
|
casterName, spell.Name, after-before, before, after, maxHP))
|
||||||
|
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"🩹 **%s** on **%s** — restored %d HP (%d → %d / %d). %s",
|
||||||
|
spell.Name, targetName, after-before, before, after, maxHP,
|
||||||
|
renderSlotsBrief(ctx.Sender)))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
|
// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||||
@@ -676,6 +755,7 @@ func renderCastHelp(c *DnDCharacter) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("**Cast a spell**\n\n")
|
b.WriteString("**Cast a spell**\n\n")
|
||||||
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
|
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
|
||||||
|
b.WriteString(" `!cast <heal> @friend` to heal someone on your expedition.\n")
|
||||||
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
|
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
|
||||||
b.WriteString("Run `!spells` for your full list.\n\n")
|
b.WriteString("Run `!spells` for your full list.\n\n")
|
||||||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||||||
|
|||||||
139
internal/plugin/dnd_cast_ally_e2e_test.go
Normal file
139
internal/plugin/dnd_cast_ally_e2e_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The whole `!cast cure wounds @friend` path, out of combat, through the real
|
||||||
|
// handler: parse → class/known/prepared gates → slot debit → the ally's sheet.
|
||||||
|
//
|
||||||
|
// The seams are unit-tested next door; this exists for the one thing they cannot
|
||||||
|
// see — that the slot ledger and the HP write agree about whether the heal
|
||||||
|
// happened. A refund that does not fire is a slot a player paid for nothing, and
|
||||||
|
// a refund that fires twice is a free heal.
|
||||||
|
|
||||||
|
// castingCleric turns an existing sheet into a cleric who knows and has prepared
|
||||||
|
// Cure Wounds, with slots to spend.
|
||||||
|
func castingCleric(t *testing.T, uid id.UserID) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
t.Fatalf("load %s: %v", uid, err)
|
||||||
|
}
|
||||||
|
c.Class = ClassCleric
|
||||||
|
c.Level = 5
|
||||||
|
c.WIS = 16
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := setSpellSlotsForCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := addKnownSpell(uid, "cure_wounds", "class", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := setSpellPrepared(uid, "cure_wounds", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func slotsUsed(t *testing.T, uid id.UserID, level int) int {
|
||||||
|
t.Helper()
|
||||||
|
slots, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return slots[level][1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCharHP(t *testing.T, uid id.UserID, hp int) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
t.Fatalf("load %s: %v", uid, err)
|
||||||
|
}
|
||||||
|
c.HPCurrent = hp
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCastAlly_OutOfCombat_HealsTheFriendAndSpendsOneSlot(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, member := seatedMember(t, "e2eheal")
|
||||||
|
castingCleric(t, leader)
|
||||||
|
setCharHP(t, member, 5) // of 20
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
|
||||||
|
t.Fatalf("cast: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mc, _ := LoadDnDCharacter(member)
|
||||||
|
if mc.HPCurrent <= 5 {
|
||||||
|
t.Fatalf("the friend is still on %d HP; the heal landed on nobody", mc.HPCurrent)
|
||||||
|
}
|
||||||
|
// The caster must NOT have healed themselves — the bug this whole section exists for.
|
||||||
|
if lc, _ := LoadDnDCharacter(leader); lc.HPCurrent != lc.HPMax {
|
||||||
|
t.Fatalf("caster HP moved to %d/%d; the heal landed on the caster", lc.HPCurrent, lc.HPMax)
|
||||||
|
}
|
||||||
|
if used := slotsUsed(t, leader, 1); used != 1 {
|
||||||
|
t.Fatalf("level-1 slots used = %d, want exactly 1", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naming a full-HP ally must cost nothing: the slot is debited before the target
|
||||||
|
// is touched, so this is the refund path firing for real.
|
||||||
|
func TestCastAlly_OutOfCombat_FullHPAllyRefundsTheSlot(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, member := seatedMember(t, "e2efull")
|
||||||
|
castingCleric(t, leader)
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
|
||||||
|
t.Fatalf("cast: %v", err)
|
||||||
|
}
|
||||||
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||||
|
t.Fatalf("level-1 slots used = %d after healing a full-HP ally; the slot was not refunded", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A target nobody can reach is refused before anything is spent.
|
||||||
|
func TestCastAlly_OutOfCombat_StrangerCostsNothing(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, _ := seatedMember(t, "e2estranger")
|
||||||
|
castingCleric(t, leader)
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @nobody"); err != nil {
|
||||||
|
t.Fatalf("cast: %v", err)
|
||||||
|
}
|
||||||
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||||
|
t.Fatalf("level-1 slots used = %d after naming a stranger; nothing should have been spent", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A target on a spell that cannot use one is refused, rather than quietly
|
||||||
|
// dropping the target and firing the spell at the caster — which is what the old
|
||||||
|
// "accept and ignore" parse did.
|
||||||
|
func TestCastAlly_OutOfCombat_TargetOnANonHealIsRefused(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, member := seatedMember(t, "e2enonheal")
|
||||||
|
castingCleric(t, leader)
|
||||||
|
if err := addKnownSpell(leader, "guiding_bolt", "class", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "guiding bolt @"+member.Localpart()); err != nil {
|
||||||
|
t.Fatalf("cast: %v", err)
|
||||||
|
}
|
||||||
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||||
|
t.Fatalf("level-1 slots used = %d; a targeted non-heal must be refused before it is paid for", used)
|
||||||
|
}
|
||||||
|
if lc, _ := LoadDnDCharacter(leader); lc.PendingCast != "" {
|
||||||
|
t.Fatalf("guiding bolt queued as %q; the target should have refused the cast outright", lc.PendingCast)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
internal/plugin/dnd_cast_target.go
Normal file
156
internal/plugin/dnd_cast_target.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Out-of-combat ally targeting for `!cast` — the other half of §1.
|
||||||
|
//
|
||||||
|
// splitCastTarget (combat_cmd.go) resolves a target against the people *in the
|
||||||
|
// fight*. Out of combat there is no fight, so the equivalent set is the people
|
||||||
|
// on your expedition: the party is who you are standing next to. Healing a
|
||||||
|
// stranger across the world is not a thing a cleric at camp can do, and scoping
|
||||||
|
// it to the party keeps the two `!cast` paths answering the same question.
|
||||||
|
//
|
||||||
|
// Only heals may name somebody else. Everything else `!cast` can do out of
|
||||||
|
// combat either resolves on the caster (UTILITY) or queues as a PendingCast for
|
||||||
|
// **the caster's** next fight — an ally target on those would be silently
|
||||||
|
// dropped, which is how `--target` came to be swallowed in the first place.
|
||||||
|
|
||||||
|
// splitOutOfCombatTarget peels an ally target off a `!cast` argument, using the
|
||||||
|
// same two spellings the combat path accepts: the `--target @alex` flag, and a
|
||||||
|
// plain trailing `@alex`. The bare form requires the `@` here (unlike in combat,
|
||||||
|
// where the roster disambiguates) so a trailing spell word is never mistaken for
|
||||||
|
// a name.
|
||||||
|
//
|
||||||
|
// Returns (remainingArgs, name). name is empty when no target was named.
|
||||||
|
func splitOutOfCombatTarget(args string) (string, string) {
|
||||||
|
fields := strings.Fields(args)
|
||||||
|
for i := 0; i < len(fields); i++ {
|
||||||
|
if !strings.EqualFold(fields[i], "--target") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(fields) {
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(fields[i+1], "@")
|
||||||
|
fields = append(fields[:i], fields[i+2:]...)
|
||||||
|
return strings.Join(fields, " "), name
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
if last := fields[len(fields)-1]; strings.HasPrefix(last, "@") {
|
||||||
|
if name := strings.TrimPrefix(last, "@"); name != "" {
|
||||||
|
return strings.Join(fields[:len(fields)-1], " "), name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCastTargetOnExpedition maps a named target to a human on the caster's
|
||||||
|
// active expedition. errMsg is non-empty and player-facing on any failure; a
|
||||||
|
// caster who names somebody must never fall through to healing themselves,
|
||||||
|
// because that quietly burns the slot on the wrong body.
|
||||||
|
func resolveCastTargetOnExpedition(caster id.UserID, name string) (id.UserID, string) {
|
||||||
|
exp, _, err := activeExpeditionFor(caster)
|
||||||
|
if err != nil {
|
||||||
|
return "", "Couldn't look up your expedition."
|
||||||
|
}
|
||||||
|
if exp == nil {
|
||||||
|
return "", "You're not travelling with anyone. `!cast` on yourself, or join a party first."
|
||||||
|
}
|
||||||
|
seats, err := partyHumans(exp.ID, exp.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "Couldn't look up your party."
|
||||||
|
}
|
||||||
|
for _, s := range seats {
|
||||||
|
uid := string(s.UserID)
|
||||||
|
if strings.EqualFold(uid, name) || strings.EqualFold(id.UserID(uid).Localpart(), name) {
|
||||||
|
if s.UserID == caster {
|
||||||
|
// Naming yourself is just casting it on yourself.
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return s.UserID, ""
|
||||||
|
}
|
||||||
|
// Character name, which is what players actually call each other.
|
||||||
|
if n := charName(s.UserID); n != "" && strings.EqualFold(n, name) {
|
||||||
|
if s.UserID == caster {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return s.UserID, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(name, companionDisplayName) {
|
||||||
|
return "", companionDisplayName + " patches himself up at camp. Save the slot."
|
||||||
|
}
|
||||||
|
return "", fmt.Sprintf("**%s** isn't on your expedition. You can only heal someone you're travelling with.", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errHealTargetFull / errHealTargetDown are the two ways an ally heal declines
|
||||||
|
// to spend the slot. Both are recoverable: the caller refunds.
|
||||||
|
var (
|
||||||
|
errHealTargetFull = errors.New("target already at full HP")
|
||||||
|
errHealTargetDown = errors.New("target is down")
|
||||||
|
)
|
||||||
|
|
||||||
|
// healPartyMember applies a heal to somebody else's sheet.
|
||||||
|
//
|
||||||
|
// It does NOT take the target's advUserLock. Gifting sets the precedent
|
||||||
|
// (adventure_gifting.go): mutate the other player's row with one guarded
|
||||||
|
// statement rather than a read-modify-write under two locks. Two clerics healing
|
||||||
|
// each other at the same instant would otherwise take those locks in opposite
|
||||||
|
// orders and deadlock the pair of them.
|
||||||
|
//
|
||||||
|
// The clamp lives in SQL for the same reason — read, add, cap and write are one
|
||||||
|
// statement, so a heal landing between a concurrent heal's read and write cannot
|
||||||
|
// push anybody past their maximum.
|
||||||
|
func healPartyMember(target id.UserID, amount int) (before, after, maxHP int, err error) {
|
||||||
|
tx, err := db.Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
err = tx.QueryRow(
|
||||||
|
`SELECT hp_current, hp_max FROM dnd_character WHERE user_id = ?`,
|
||||||
|
string(target)).Scan(&before, &maxHP)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, 0, 0, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case before <= 0:
|
||||||
|
// A heal is not a resurrection — the same rule the combat path holds.
|
||||||
|
return before, before, maxHP, errHealTargetDown
|
||||||
|
case before >= maxHP:
|
||||||
|
return before, before, maxHP, errHealTargetFull
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec(
|
||||||
|
`UPDATE dnd_character
|
||||||
|
SET hp_current = MIN(hp_max, hp_current + ?),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND hp_current > 0`,
|
||||||
|
amount, string(target)); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
if err = tx.QueryRow(
|
||||||
|
`SELECT hp_current FROM dnd_character WHERE user_id = ?`,
|
||||||
|
string(target)).Scan(&after); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
return before, after, maxHP, nil
|
||||||
|
}
|
||||||
102
internal/plugin/dnd_cast_target_test.go
Normal file
102
internal/plugin/dnd_cast_target_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The out-of-combat half of §1. `--target` was parsed and thrown away here since
|
||||||
|
// SP2 ("reserved for SP3, accept and ignore"), so a party cleric standing over a
|
||||||
|
// bleeding friend between fights could do precisely nothing for them.
|
||||||
|
|
||||||
|
func TestSplitOutOfCombatTarget(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, args, wantRest, wantTarget string
|
||||||
|
}{
|
||||||
|
{"flag", "cure wounds --target @alex", "cure wounds", "alex"},
|
||||||
|
{"flag mid-string", "cure wounds --target @alex --upcast 3", "cure wounds --upcast 3", "alex"},
|
||||||
|
{"flag without the @", "cure wounds --target alex", "cure wounds", "alex"},
|
||||||
|
{"trailing mention", "cure wounds @alex", "cure wounds", "alex"},
|
||||||
|
{"no target", "cure wounds", "cure wounds", ""},
|
||||||
|
// A trailing bare word is a spell word, not a name — the `@` is what makes
|
||||||
|
// it a target out of combat. Getting this wrong eats half of "cure wounds".
|
||||||
|
{"bare trailing word is not a target", "healing word", "healing word", ""},
|
||||||
|
{"upcast digit is not a target", "cure wounds --upcast 3", "cure wounds --upcast 3", ""},
|
||||||
|
{"dangling flag", "cure wounds --target", "cure wounds --target", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rest, target := splitOutOfCombatTarget(tc.args)
|
||||||
|
if rest != tc.wantRest || target != tc.wantTarget {
|
||||||
|
t.Fatalf("splitOutOfCombatTarget(%q) = (%q, %q), want (%q, %q)",
|
||||||
|
tc.args, rest, target, tc.wantRest, tc.wantTarget)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The target set is the expedition, not the world: you can heal the person you
|
||||||
|
// are travelling with, and nobody else.
|
||||||
|
func TestResolveCastTarget_OnlyThePartyIsReachable(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, member := seatedMember(t, "casttarget")
|
||||||
|
|
||||||
|
uid, errMsg := resolveCastTargetOnExpedition(leader, member.Localpart())
|
||||||
|
if errMsg != "" || uid != member {
|
||||||
|
t.Fatalf("leader → member = (%q, %q); want the member, no error", uid, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody with a sheet, but not on this expedition.
|
||||||
|
stranger := id.UserID("@stranger-casttarget:example.org")
|
||||||
|
zoneCmdTestCharacter(t, stranger, 1)
|
||||||
|
if _, errMsg := resolveCastTargetOnExpedition(leader, stranger.Localpart()); errMsg == "" {
|
||||||
|
t.Fatal("healed a stranger across the world; only the party is reachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naming yourself is just casting it on yourself: no target, no error. A
|
||||||
|
// refusal here would make `!cast cure wounds @me` cost a slot for nothing.
|
||||||
|
if uid, errMsg := resolveCastTargetOnExpedition(leader, leader.Localpart()); uid != "" || errMsg != "" {
|
||||||
|
t.Fatalf("self-target = (%q, %q); want the self-heal path, silently", uid, errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealPartyMember_ClampsAtMaxAndWillNotRaiseTheDead(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
target := id.UserID("@heal-target:example.org")
|
||||||
|
zoneCmdTestCharacter(t, target, 1) // HPMax 20
|
||||||
|
|
||||||
|
setHP := func(hp int) {
|
||||||
|
c, err := LoadDnDCharacter(target)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
c.HPCurrent = hp
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A heal bigger than the wound tops out at max rather than overflowing.
|
||||||
|
setHP(15)
|
||||||
|
before, after, maxHP, err := healPartyMember(target, 100)
|
||||||
|
if err != nil || before != 15 || after != 20 || maxHP != 20 {
|
||||||
|
t.Fatalf("overheal = (%d → %d / %d, %v); want clamped to 20", before, after, maxHP, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already full: decline so the caller can refund. Spending a slot to heal
|
||||||
|
// zero HP is the kind of thing players never forgive.
|
||||||
|
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetFull) {
|
||||||
|
t.Fatalf("healing a full-HP ally = %v; want errHealTargetFull", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down: a heal is not a resurrection. Same rule the combat path holds.
|
||||||
|
setHP(0)
|
||||||
|
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetDown) {
|
||||||
|
t.Fatalf("healing a downed ally = %v; want errHealTargetDown", err)
|
||||||
|
}
|
||||||
|
if c, _ := LoadDnDCharacter(target); c == nil || c.HPCurrent != 0 {
|
||||||
|
t.Fatal("the downed ally was raised by a heal")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,6 +307,13 @@ func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*D
|
|||||||
c := autoBuildCharacter(userID, char)
|
c := autoBuildCharacter(userID, char)
|
||||||
c.AutoMigrated = true
|
c.AutoMigrated = true
|
||||||
c.PendingSetup = false
|
c.PendingSetup = false
|
||||||
|
// Seed the canonical player_meta row before handing back a confirmed
|
||||||
|
// character. Auto-migration otherwise mints a player_meta-less character
|
||||||
|
// (the camcast straggler) that fails every legacy-layer command with
|
||||||
|
// "sql: no rows". No-op for legacy players who already have the row.
|
||||||
|
if err := ensurePlayerMetaSeed(userID); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("seed player_meta: %w", err)
|
||||||
|
}
|
||||||
if err := SaveDnDCharacter(c); err != nil {
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -567,4 +576,80 @@ func markAdventureDead(userID id.UserID, source, location string) {
|
|||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
|
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
|
||||||
}
|
}
|
||||||
|
emitDeathNews(userID, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitDeathNews files a PRIORITY death dispatch to Pete's adventure news. No-op
|
||||||
|
// unless the seam is enabled. Uses the character name (never the Matrix handle);
|
||||||
|
// skips silently if the name is unknown.
|
||||||
|
func emitDeathNews(userID id.UserID, location string) {
|
||||||
|
// Gate before the char lookups: markAdventureDead fires per-member on a
|
||||||
|
// party wipe and in the sim harness, so skip the DB reads when disabled.
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := charName(userID)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lvl := charLevel(userID)
|
||||||
|
ts := nowUnix()
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
|
||||||
|
EventType: "death",
|
||||||
|
Tier: "priority",
|
||||||
|
Subject: name,
|
||||||
|
Zone: location,
|
||||||
|
Level: lvl,
|
||||||
|
Outcome: "lost",
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, userID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitRetreatNews files a BULLETIN when an expedition ends with the player
|
||||||
|
// walking out alive.
|
||||||
|
//
|
||||||
|
// Until this existed the news had no way to say "it went badly but nobody
|
||||||
|
// died", so it never said it. Pete's whole taxonomy was arrival, companion_hire,
|
||||||
|
// death, milestone, rival_result and zone_first — every one of them a win, a
|
||||||
|
// death, or an introduction. A run that simply fell apart emitted nothing, and
|
||||||
|
// the feed showed a realm where adventurers only ever triumph or die.
|
||||||
|
//
|
||||||
|
// That was not a rare gap. Before §6, a cleric retreated on 167 of 500
|
||||||
|
// simulated expeditions: a third of that class's runs ended in a way the news
|
||||||
|
// was structurally incapable of reporting. Casters did not look unlucky in the
|
||||||
|
// feed — they looked absent.
|
||||||
|
//
|
||||||
|
// Bulletin, not priority: a retreat is a bad day, not a funeral. A death already
|
||||||
|
// files its own priority dispatch from markAdventureDead, and a wipe that killed
|
||||||
|
// someone must not ALSO be reported as a retreat — hence the reason gate rather
|
||||||
|
// than an "expedition ended" catch-all. An idle reap is excluded too: a player
|
||||||
|
// who closed their laptop did not flee anything, and Pete announcing that they
|
||||||
|
// were driven from the field would be a lie about a person by name.
|
||||||
|
func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) {
|
||||||
|
switch reason {
|
||||||
|
case lossCombatRetreat, lossCombatFlee:
|
||||||
|
default:
|
||||||
|
return // a death tells its own story; an idle reap is not a story
|
||||||
|
}
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := charName(userID)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zone := zoneOrFallback(zoneID)
|
||||||
|
ts := nowUnix()
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("retreat:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
|
||||||
|
EventType: "retreat",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: name,
|
||||||
|
Zone: zone.Display,
|
||||||
|
Level: charLevel(userID),
|
||||||
|
Count: day, // the day they got to before it fell apart
|
||||||
|
Outcome: "retreated",
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, userID, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
|||||||
if isHol, _ := isHolidayToday(); isHol {
|
if isHol, _ := isHolidayToday(); isHol {
|
||||||
startMood = 55
|
startMood = 55
|
||||||
}
|
}
|
||||||
|
if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
|
||||||
|
startMood += b
|
||||||
|
}
|
||||||
exp := &Expedition{
|
exp := &Expedition{
|
||||||
ID: newExpeditionID(),
|
ID: newExpeditionID(),
|
||||||
UserID: string(userID),
|
UserID: string(userID),
|
||||||
|
|||||||
@@ -325,6 +325,13 @@ func applyCampRest(e *Expedition, kind string) string {
|
|||||||
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
|
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
|
||||||
_ = refreshAllResources(uid)
|
_ = refreshAllResources(uid)
|
||||||
_ = refreshSpellSlots(uid)
|
_ = refreshSpellSlots(uid)
|
||||||
|
// The companion sleeps at the same fire. His slots and his wounds are not
|
||||||
|
// dnd_spell_slots / dnd_character rows — he has none — so nothing above
|
||||||
|
// reaches him, and without these two his pool and his body would only ever
|
||||||
|
// go down. Camp is where a caster gets their slots back and a body gets
|
||||||
|
// patched up, and he is at the camp.
|
||||||
|
_ = refreshCompanionSlots(e.ID)
|
||||||
|
_ = refreshCompanionHP(e.ID)
|
||||||
_ = ReplenishHarvestNodes(e)
|
_ = ReplenishHarvestNodes(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
return p.expeditionCmdParty(ctx)
|
return p.expeditionCmdParty(ctx)
|
||||||
case "leave":
|
case "leave":
|
||||||
return p.expeditionCmdLeave(ctx)
|
return p.expeditionCmdLeave(ctx)
|
||||||
|
case "hire":
|
||||||
|
return p.expeditionCmdHire(ctx, rest)
|
||||||
|
case "dismiss":
|
||||||
|
return p.expeditionCmdDismiss(ctx)
|
||||||
case "extract":
|
case "extract":
|
||||||
return p.handleExtractCmd(ctx, "")
|
return p.handleExtractCmd(ctx, "")
|
||||||
case "resume":
|
case "resume":
|
||||||
@@ -122,7 +126,9 @@ func expeditionHelpText() string {
|
|||||||
b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
|
b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
|
||||||
b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
|
b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
|
||||||
b.WriteString("`!expedition party` — who's with you\n")
|
b.WriteString("`!expedition party` — who's with you\n")
|
||||||
b.WriteString("`!expedition leave` — turn back for town (members only)\n\n")
|
b.WriteString("`!expedition leave` — turn back for town (members only)\n")
|
||||||
|
b.WriteString("`!expedition hire [class]` — pay Pete to fill the role you're missing\n")
|
||||||
|
b.WriteString("`!expedition dismiss` — send Pete home\n\n")
|
||||||
b.WriteString("**Mid-expedition:**\n")
|
b.WriteString("**Mid-expedition:**\n")
|
||||||
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
|
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
|
||||||
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
|
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
|
||||||
@@ -384,6 +390,9 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
if isHol, _ := isHolidayToday(); isHol {
|
if isHol, _ := isHolidayToday(); isHol {
|
||||||
suppliesPurchase.StandardPacks++
|
suppliesPurchase.StandardPacks++
|
||||||
}
|
}
|
||||||
|
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
||||||
|
suppliesPurchase.StandardPacks += pk
|
||||||
|
}
|
||||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||||
|
|
||||||
// Debit coins; bail on debit failure (race / cap).
|
// Debit coins; bail on debit failure (race / cap).
|
||||||
|
|||||||
@@ -95,21 +95,40 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
|
|||||||
return e, tax, nil
|
return e, tax, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reasons a run can end badly. They were bare strings at four call sites;
|
||||||
|
// they are constants now because the news seam has to tell them apart — a
|
||||||
|
// retreat is a story and a death is a different story, and an idle reap is
|
||||||
|
// neither.
|
||||||
|
const (
|
||||||
|
lossCombatDeath = "combat death"
|
||||||
|
lossCombatRetreat = "combat retreat" // solo: ran out the phase clock and withdrew
|
||||||
|
lossCombatFlee = "combat flee" // party: the turn engine broke off
|
||||||
|
lossIdleTimeout = "run idle-timeout (§4.3 stale-run reap)"
|
||||||
|
)
|
||||||
|
|
||||||
// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based
|
// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based
|
||||||
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
||||||
// death) into the forced-extract flow. Those sites already abandon the
|
// death) into the forced-extract flow. Those sites already abandon the
|
||||||
// zone run, but without flipping the wrapping expedition the ambient
|
// zone run, but without flipping the wrapping expedition the ambient
|
||||||
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
||||||
// when there is no active expedition for this user.
|
// when there is no active expedition for this user.
|
||||||
|
//
|
||||||
|
// It is also the one chokepoint every bad ending passes through, which makes it
|
||||||
|
// where the retreat dispatch is filed. Read the expedition BEFORE the extract:
|
||||||
|
// forcedExtractExpedition stamps it 'abandoned' and zeroes the live fields, so
|
||||||
|
// afterwards there is no day count left to report.
|
||||||
func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
|
func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
|
||||||
exp, err := getActiveExpedition(userID)
|
exp, err := getActiveExpedition(userID)
|
||||||
if err != nil || exp == nil {
|
if err != nil || exp == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
day, zoneID := exp.CurrentDay, exp.ZoneID
|
||||||
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
||||||
slog.Warn("expedition: force-extract on run loss",
|
slog.Warn("expedition: force-extract on run loss",
|
||||||
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
emitRetreatNews(userID, reason, zoneID, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
||||||
@@ -177,6 +196,9 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
|
|||||||
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
|
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
|
||||||
lines = append(lines, sl)
|
lines = append(lines, sl)
|
||||||
}
|
}
|
||||||
|
// News: boss down means the zone is cleared. Realm-first → PRIORITY,
|
||||||
|
// repeat → BULLETIN. No-op unless the Pete seam is enabled.
|
||||||
|
emitZoneClearNews(userID, exp)
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
|
|||||||
if isHol, _ := isHolidayToday(); isHol {
|
if isHol, _ := isHolidayToday(); isHol {
|
||||||
qty++
|
qty++
|
||||||
}
|
}
|
||||||
|
if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen
|
||||||
|
qty += b
|
||||||
|
}
|
||||||
tier := zoneTierFromID(res.ZoneID)
|
tier := zoneTierFromID(res.ZoneID)
|
||||||
for i := 0; i < qty; i++ {
|
for i := 0; i < qty; i++ {
|
||||||
item := AdvItem{
|
item := AdvItem{
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
|||||||
return 0, "", nil
|
return 0, "", nil
|
||||||
}
|
}
|
||||||
delta, reason := dailyThreatDrift(e.DMMood)
|
delta, reason := dailyThreatDrift(e.DMMood)
|
||||||
|
// N7/B3 the Omen — a "still waters" week subtracts from the daily threat
|
||||||
|
// *rise* only (guarded on delta > 0), floored so the worst case is threat
|
||||||
|
// holding steady for the day; it never turns a rise into active decay.
|
||||||
|
if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 {
|
||||||
|
if delta -= r; delta < 0 {
|
||||||
|
delta = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
if delta == 0 {
|
if delta == 0 {
|
||||||
return 0, reason, nil
|
return 0, reason, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) error {
|
|||||||
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
|
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
|
||||||
if c.Level >= dndMaxLevel {
|
if c.Level >= dndMaxLevel {
|
||||||
b.WriteString("XP: capped at L20.")
|
b.WriteString("XP: capped at L20.")
|
||||||
|
if rl := advChar.RenownLevel(); rl > 0 {
|
||||||
|
into, cost := renownXPIntoLevel(advChar.RenownXP)
|
||||||
|
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
|
||||||
|
if rank := renownRankFor(rl); rank != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(" — _%s_", rank))
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
|
||||||
|
} else {
|
||||||
|
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
next := dndXPToNextLevel(c.Level)
|
next := dndXPToNextLevel(c.Level)
|
||||||
pct := int(100.0 * float64(c.XP) / float64(next))
|
pct := int(100.0 * float64(c.XP) / float64(next))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -290,6 +291,22 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
|
|||||||
// Idempotent — !setup confirm after a respec wipe will repopulate.
|
// Idempotent — !setup confirm after a respec wipe will repopulate.
|
||||||
_ = ensureSpellsForCharacter(c)
|
_ = ensureSpellsForCharacter(c)
|
||||||
|
|
||||||
|
// A new character walked through the gates — file a welcome dispatch to
|
||||||
|
// Pete. GUID keyed on the user alone so a later respec/re-confirm can't
|
||||||
|
// re-announce the same arrival. No-op unless the seam is enabled.
|
||||||
|
if name := charName(ctx.Sender); name != "" {
|
||||||
|
ts := nowUnix()
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: "arrival:" + eventToken(ctx.Sender, "arrival"),
|
||||||
|
EventType: "arrival",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: name,
|
||||||
|
ClassRace: classRaceLabel(c),
|
||||||
|
Level: c.Level,
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, ctx.Sender, "")
|
||||||
|
}
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, renderSetupComplete(c))
|
return p.SendDM(ctx.Sender, renderSetupComplete(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,20 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
|||||||
if adv != nil && adv.DisplayName != "" {
|
if adv != nil && adv.DisplayName != "" {
|
||||||
name = adv.DisplayName
|
name = adv.DisplayName
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
|
renownLevel := 0
|
||||||
|
if adv != nil {
|
||||||
|
renownLevel = adv.RenownLevel()
|
||||||
|
}
|
||||||
|
nameLine := name
|
||||||
|
if m := renownMarker(renownLevel); m != "" {
|
||||||
|
nameLine = fmt.Sprintf("%s %s", name, m)
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", nameLine, c.Level, ri.Display, ci.Display))
|
||||||
|
if renownLevel > 0 {
|
||||||
|
if rank := renownRankFor(renownLevel); rank != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(" _Renown %d — %s_\n", renownLevel, rank))
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.Subclass != "" {
|
if c.Subclass != "" {
|
||||||
if si, ok := subclassInfo(c.Subclass); ok {
|
if si, ok := subclassInfo(c.Subclass); ok {
|
||||||
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
|
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
|
||||||
|
|||||||
@@ -127,15 +127,26 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
|
|||||||
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
|
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at L20 — overflow XP is silently dropped.
|
// N7/B2 Renown — persist the character; at the cap, overflow XP that used to
|
||||||
if c.Level >= dndMaxLevel {
|
// be dropped converts to Renown in the SAME transaction as the save, so a
|
||||||
|
// crash can neither lose the overflow nor double-credit it on the next grant.
|
||||||
|
// Renown is title + cosmetic + capped loot/XP perks, never combat stats, so
|
||||||
|
// the balance corpus stays valid.
|
||||||
|
var renownFrom, renownTo int
|
||||||
|
if c.Level >= dndMaxLevel && c.XP > 0 {
|
||||||
|
overflow := c.XP
|
||||||
c.XP = 0
|
c.XP = 0
|
||||||
}
|
from, to, err := saveDnDCharacterWithOverflow(c, overflow)
|
||||||
|
if err != nil {
|
||||||
if err := SaveDnDCharacter(c); err != nil {
|
return events, err
|
||||||
|
}
|
||||||
|
renownFrom, renownTo = renownLevelFor(from), renownLevelFor(to)
|
||||||
|
} else if err := SaveDnDCharacter(c); err != nil {
|
||||||
return events, err
|
return events, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.announceRenown(userID, renownFrom, renownTo)
|
||||||
|
|
||||||
if len(events) > 0 {
|
if len(events) > 0 {
|
||||||
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
|
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
|
||||||
// superiority cap from 4 → 5; reconcile any level-gated subclass pool
|
// superiority cap from 4 → 5; reconcile any level-gated subclass pool
|
||||||
|
|||||||
@@ -1207,9 +1207,9 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
// seat off HP, which for a solo walker is the same `!TimedOut` rule.
|
// seat off HP, which for a solo walker is the same `!TimedOut` rule.
|
||||||
closeOutZoneLoss(pres, seated, zone, "zone")
|
closeOutZoneLoss(pres, seated, zone, "zone")
|
||||||
if !result.TimedOut {
|
if !result.TimedOut {
|
||||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
forceExtractExpeditionForRunLoss(userID, lossCombatDeath)
|
||||||
} else {
|
} else {
|
||||||
forceExtractExpeditionForRunLoss(userID, "combat retreat")
|
forceExtractExpeditionForRunLoss(userID, lossCombatRetreat)
|
||||||
}
|
}
|
||||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
|
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
|
||||||
ob.WriteString(line)
|
ob.WriteString(line)
|
||||||
|
|||||||
@@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{
|
|||||||
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
||||||
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
||||||
func rollZoneIngredient(zoneTier int) *AdvItem {
|
func rollZoneIngredient(zoneTier int) *AdvItem {
|
||||||
if rand.Float64() >= advIngredientDropChance {
|
chance := advIngredientDropChance
|
||||||
|
if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen
|
||||||
|
chance *= m
|
||||||
|
}
|
||||||
|
if rand.Float64() >= chance {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
|||||||
// but only when this run is the active expedition's current run so
|
// but only when this run is the active expedition's current run so
|
||||||
// a standalone (non-expedition) stale run still reaps cleanly.
|
// a standalone (non-expedition) stale run still reaps cleanly.
|
||||||
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
|
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
|
||||||
forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)")
|
forceExtractExpeditionForRunLoss(userID, lossIdleTimeout)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
290
internal/plugin/exercise_prod_test.go
Normal file
290
internal/plugin/exercise_prod_test.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
//go:build prodexercise
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
// Headless smoke-exercise of the N-series features (parties aside — those have
|
||||||
|
// their own sim path) against a COPY of the prod DB. Nothing here touches the
|
||||||
|
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
|
||||||
|
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
|
||||||
|
// before opening it, so even the copy is left pristine.
|
||||||
|
//
|
||||||
|
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||||
|
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
|
||||||
|
//
|
||||||
|
// Every outbound DM / room message a handler would have sent to Matrix is
|
||||||
|
// captured via the existing MessageSink seam and dumped with t.Log, so the run
|
||||||
|
// shows exactly what a player would see. Each feature is wrapped so a panic or
|
||||||
|
// error in one is reported and the battery continues.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExerciseNewFeaturesProd(t *testing.T) {
|
||||||
|
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||||||
|
if srcDir == "" {
|
||||||
|
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||||||
|
t.Skipf("no gogobee.db in %s", srcDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||||||
|
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(tmp); err != nil {
|
||||||
|
t.Fatalf("db.Init: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
|
||||||
|
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
|
||||||
|
// so the same DB copy exercises identically regardless of which week we run.
|
||||||
|
simOmenDisabled = true
|
||||||
|
simAutoArmEnabled = true
|
||||||
|
|
||||||
|
euro := NewEuroPlugin(nil)
|
||||||
|
xp := NewXPPlugin(nil)
|
||||||
|
p := NewAdventurePlugin(nil, euro, xp)
|
||||||
|
sink := &captureSink{}
|
||||||
|
p.Sink = sink
|
||||||
|
mark := 0
|
||||||
|
drain := func() {
|
||||||
|
for _, m := range sink.msgs[mark:] {
|
||||||
|
who := string(m.ToUser)
|
||||||
|
if who == "" {
|
||||||
|
who = "room:" + string(m.ToRoom)
|
||||||
|
}
|
||||||
|
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||||||
|
}
|
||||||
|
mark = len(sink.msgs)
|
||||||
|
}
|
||||||
|
exercise := func(name string, fn func()) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Logf(" !! %s PANICKED: %v", name, r)
|
||||||
|
mark = len(sink.msgs)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Logf("──────── %s ────────", name)
|
||||||
|
fn()
|
||||||
|
drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
chars := runnableChars(t)
|
||||||
|
if len(chars) == 0 {
|
||||||
|
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
|
||||||
|
}
|
||||||
|
t.Logf("runnable characters: %d", len(chars))
|
||||||
|
for _, c := range chars {
|
||||||
|
healToFull(t, c.uid)
|
||||||
|
euro.Credit(c.uid, 5000, "exercise bankroll")
|
||||||
|
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
|
||||||
|
}
|
||||||
|
ctxFor := func(uid id.UserID, body string) MessageContext {
|
||||||
|
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
|
||||||
|
exercise("N7/B2 Renown — standing + prestige announce", func() {
|
||||||
|
for _, c := range chars {
|
||||||
|
xp, _ := loadRenownXP(c.uid)
|
||||||
|
lvl := renownLevelForUser(c.uid)
|
||||||
|
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
|
||||||
|
}
|
||||||
|
// Push the first character up a renown level to fire the announce path.
|
||||||
|
c0 := chars[0]
|
||||||
|
before, after, err := addRenownXP(c0.uid, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" addRenownXP: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
|
||||||
|
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
|
||||||
|
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
|
||||||
|
boss, err := p.spawnWorldBoss("exercise-siege")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" spawnWorldBoss: %v", err)
|
||||||
|
}
|
||||||
|
if boss != nil {
|
||||||
|
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
|
||||||
|
}
|
||||||
|
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||||
|
for _, c := range chars {
|
||||||
|
t.Logf(" -- %s takes a bout --", c.uid)
|
||||||
|
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
|
||||||
|
}
|
||||||
|
t.Logf(" -- pool after the raid --")
|
||||||
|
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
|
||||||
|
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
|
||||||
|
if len(chars) < 2 {
|
||||||
|
t.Log(" need 2 characters for a duel; skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, b := chars[0], chars[1]
|
||||||
|
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
|
||||||
|
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
|
||||||
|
exercise("N6/D3 Shadow — standing vs the rival", func() {
|
||||||
|
for _, c := range chars {
|
||||||
|
p.handleShadowCmd(ctxFor(c.uid, ""))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
|
||||||
|
exercise("N5/D1a Journal — collected campaign pages", func() {
|
||||||
|
for _, c := range chars {
|
||||||
|
p.handleJournalCmd(ctxFor(c.uid, ""))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N4/E3 Town registries ───────────────────────────────────────────────
|
||||||
|
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
|
||||||
|
p.handleTownCmd(ctxFor(chars[0].uid, ""))
|
||||||
|
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
|
||||||
|
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
|
||||||
|
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
|
||||||
|
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
|
||||||
|
for _, c := range chars {
|
||||||
|
p.handleVaultCmd(ctxFor(c.uid, ""), "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
|
||||||
|
exercise("N4/E2 Gifting — !give <item> @user", func() {
|
||||||
|
if len(chars) < 2 {
|
||||||
|
t.Log(" need 2 characters to gift; skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
giver, receiver := chars[0], chars[1]
|
||||||
|
item := firstInventoryItem(giver.uid)
|
||||||
|
if item == "" {
|
||||||
|
t.Logf(" %s has no inventory items to gift", giver.uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
|
||||||
|
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
|
||||||
|
p.handleGiveCmd(ctxFor(giver.uid, body))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
|
||||||
|
exercise("N7/B4 Achievements — !achievements", func() {
|
||||||
|
ach := NewAchievementsPlugin(nil, nil)
|
||||||
|
ach.Sink = sink
|
||||||
|
p.SetAchievements(ach)
|
||||||
|
for _, c := range chars {
|
||||||
|
ach.handleAchievements(ctxFor(c.uid, ""))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
type exChar struct {
|
||||||
|
uid id.UserID
|
||||||
|
class string
|
||||||
|
level int
|
||||||
|
}
|
||||||
|
|
||||||
|
// runnableChars lists characters that have both a completed dnd_character (class
|
||||||
|
// set) and the legacy adventure_characters row the expedition/feature paths load.
|
||||||
|
func runnableChars(t *testing.T) []exChar {
|
||||||
|
t.Helper()
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT d.user_id, d.class, d.dnd_level
|
||||||
|
FROM dnd_character d
|
||||||
|
JOIN adventure_characters a ON a.user_id = d.user_id
|
||||||
|
WHERE d.class != ''`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query chars: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []exChar
|
||||||
|
for rows.Next() {
|
||||||
|
var c exChar
|
||||||
|
var uid string
|
||||||
|
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.uid = id.UserID(uid)
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func healToFull(t *testing.T, uid id.UserID) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
t.Logf(" healToFull: load %s: %v", uid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.HPCurrent = c.HPMax
|
||||||
|
c.TempHP = 0
|
||||||
|
c.Exhaustion = 0
|
||||||
|
c.ShortRestCharges = c.Level
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Logf(" healToFull: save %s: %v", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstInventoryItem(uid id.UserID) string {
|
||||||
|
d := db.Get()
|
||||||
|
var name string
|
||||||
|
// adventure_inventory holds the player's consumables/misc, one row per item.
|
||||||
|
_ = d.QueryRow(
|
||||||
|
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
|
||||||
|
string(uid),
|
||||||
|
).Scan(&name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyIfPresent(t *testing.T, src, dst string) {
|
||||||
|
t.Helper()
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return // wal/shm may be absent — fine
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(s string) string {
|
||||||
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||||
|
for i, l := range lines {
|
||||||
|
lines[i] = " " + l
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -230,7 +230,7 @@ func ambientEventMonologue() ambientEvent {
|
|||||||
// it's cheap to test in isolation and free of package-level state.
|
// it's cheap to test in isolation and free of package-level state.
|
||||||
func ambientEvents() []ambientEvent {
|
func ambientEvents() []ambientEvent {
|
||||||
always := func(*Expedition) bool { return true }
|
always := func(*Expedition) bool { return true }
|
||||||
return []ambientEvent{
|
events := []ambientEvent{
|
||||||
ambientEventMonologue(),
|
ambientEventMonologue(),
|
||||||
{
|
{
|
||||||
Kind: "small_find",
|
Kind: "small_find",
|
||||||
@@ -280,6 +280,19 @@ func ambientEvents() []ambientEvent {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
|
||||||
|
// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
|
||||||
|
// reward come from activeSeason(); the ambient seam is production-only, so
|
||||||
|
// this never reaches the balance sim.
|
||||||
|
if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
|
||||||
|
events = append(events, ambientEvent{
|
||||||
|
Kind: "season_visitor",
|
||||||
|
Pool: s.Visitor.FlavorPool,
|
||||||
|
Weight: s.Visitor.Weight,
|
||||||
|
Eligible: func(*Expedition) bool { return true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
|
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
|
||||||
@@ -395,6 +408,39 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "+1 HP"
|
return "+1 HP"
|
||||||
|
case "season_visitor":
|
||||||
|
// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
|
||||||
|
// the season so the reward matches the current window even if it turned
|
||||||
|
// over between pick and apply; bail quietly if it just ended.
|
||||||
|
s, ok := activeSeason()
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v := s.Visitor
|
||||||
|
uid := id.UserID(e.UserID)
|
||||||
|
if err := addAdvInventoryItem(uid, AdvItem{
|
||||||
|
Name: v.Keepsake,
|
||||||
|
Type: "trophy",
|
||||||
|
Tier: 1,
|
||||||
|
Value: v.KeepsakeValue,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v.Coins > 0 {
|
||||||
|
if p.euro != nil {
|
||||||
|
p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
UPDATE dnd_expedition
|
||||||
|
SET coins_earned = coins_earned + ?,
|
||||||
|
last_activity = CURRENT_TIMESTAMP
|
||||||
|
WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
|
||||||
|
slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("You keep the %s.", v.Keepsake)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
115
internal/plugin/expedition_companion_cmd.go
Normal file
115
internal/plugin/expedition_companion_cmd.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The player-facing surface for hiring Pete.
|
||||||
|
//
|
||||||
|
// !expedition hire [class] (leader, Day 1 — auto-fills the missing role)
|
||||||
|
// !expedition dismiss (leader — sends him home, no refund)
|
||||||
|
//
|
||||||
|
// He is hired on Day 1 like any other companion, because a body that materializes
|
||||||
|
// three rooms in is not a party member, it is a cheat code. See
|
||||||
|
// adventure_companion.go for why he is an NPC seat and not a player.
|
||||||
|
|
||||||
|
// expeditionCmdHire brings the correspondent along.
|
||||||
|
func (p *AdventurePlugin) expeditionCmdHire(ctx MessageContext, rest string) error {
|
||||||
|
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||||
|
}
|
||||||
|
if exp == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "No active expedition. `!expedition start <zone>` first.")
|
||||||
|
}
|
||||||
|
if !isLeader {
|
||||||
|
return p.SendDM(ctx.Sender, "Only the party leader hires. You're along for the ride.")
|
||||||
|
}
|
||||||
|
if !inviteWindowOpen(exp) {
|
||||||
|
return p.SendDM(ctx.Sender,
|
||||||
|
"This expedition has already set off — Pete joins on Day 1 or not at all.")
|
||||||
|
}
|
||||||
|
if companionSeated(exp.ID) {
|
||||||
|
return p.SendDM(ctx.Sender, "Pete's already with you. `!expedition party` to see the roster.")
|
||||||
|
}
|
||||||
|
if p.euro == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
||||||
|
}
|
||||||
|
|
||||||
|
zone := zoneOrFallback(exp.ZoneID)
|
||||||
|
level := companionPartyLevel(exp.ID)
|
||||||
|
|
||||||
|
class, explicit := parseCompanionClass(rest)
|
||||||
|
if !explicit {
|
||||||
|
if arg := strings.TrimSpace(rest); arg != "" && !strings.EqualFold(arg, "auto") {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"Don't know the class %q. Name one, or just `!expedition hire` and he'll fill whatever you're missing.", arg))
|
||||||
|
}
|
||||||
|
class = companionRoleFill(companionPartyClasses(exp.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
cost := float64(companionHireCost(level, zone.Tier))
|
||||||
|
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"Pete doesn't work for free. His fee for **%s** is **%d** — you have **%.0f**.",
|
||||||
|
zone.Display, int(cost), balance))
|
||||||
|
}
|
||||||
|
if !p.euro.Debit(ctx.Sender, cost, "expedition: hired Pete") {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't debit Pete's fee (try again).")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hireCompanion(exp.ID, class, level); err != nil {
|
||||||
|
p.euro.Credit(ctx.Sender, cost, "expedition: Pete hire refund")
|
||||||
|
return p.SendDM(ctx.Sender, companionRefusalText(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
emitCompanionHireFact(ctx.Sender, class, level, zone)
|
||||||
|
|
||||||
|
ci, _ := classInfo(class)
|
||||||
|
filled := "You asked for a " + ci.Display + "."
|
||||||
|
if !explicit {
|
||||||
|
filled = "He's filling the hole in your roster — **" + ci.Display + "**."
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"📝 **Pete's coming with you into %s.** %s He's **level %d**, and his fee was **%d**.\n\n"+
|
||||||
|
"He fights his own turns — you don't command him. He takes no loot and no XP; he's here for the story.\n"+
|
||||||
|
"`!expedition dismiss` sends him home (no refund — he's already packed).",
|
||||||
|
zone.Display, filled, level, int(cost)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// expeditionCmdDismiss sends him home mid-run.
|
||||||
|
func (p *AdventurePlugin) expeditionCmdDismiss(ctx MessageContext) error {
|
||||||
|
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||||
|
}
|
||||||
|
if exp == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "No active expedition.")
|
||||||
|
}
|
||||||
|
if !isLeader {
|
||||||
|
return p.SendDM(ctx.Sender, "Only the party leader can dismiss him.")
|
||||||
|
}
|
||||||
|
if err := dismissCompanion(exp.ID); err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, companionRefusalText(err))
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender,
|
||||||
|
"📝 **Pete heads back.** He got what he came for. The fee stays spent.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionRefusalText turns the companion layer's sentinel errors into copy.
|
||||||
|
func companionRefusalText(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrCompanionAlreadyHired):
|
||||||
|
return "Pete's already with you."
|
||||||
|
case errors.Is(err, ErrCompanionOnAssignment):
|
||||||
|
return "Pete's out on assignment with another party. He'll be back."
|
||||||
|
case errors.Is(err, ErrCompanionNotHired):
|
||||||
|
return "Pete isn't with you."
|
||||||
|
case errors.Is(err, ErrPartyFull):
|
||||||
|
return "No room — your party's full. Someone has to `!expedition leave` first."
|
||||||
|
default:
|
||||||
|
return "Couldn't hire Pete: " + err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,30 +82,126 @@ func partyMembers(expeditionID string) ([]PartyMember, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what
|
// PartySeatKind is what a seat *is*. The three answers differ in ways that matter
|
||||||
// the fan-out seams (digest, briefing, recap) want: a solo expedition yields
|
// at almost every seam, and conflating any two of them has already cost us a bug:
|
||||||
// just the owner, so a caller can loop unconditionally.
|
// a companion is a seat but not a mouth and not a mailbox; a leader owns the
|
||||||
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
|
// expedition row that everyone else references.
|
||||||
members, err := partyMembers(expeditionID)
|
type PartySeatKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeatLeader PartySeatKind = iota
|
||||||
|
SeatMember
|
||||||
|
SeatCompanion
|
||||||
|
)
|
||||||
|
|
||||||
|
// PartySeat is one body on an expedition.
|
||||||
|
type PartySeat struct {
|
||||||
|
UserID id.UserID
|
||||||
|
Kind PartySeatKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHuman reports whether there is a person behind this seat — someone who can be
|
||||||
|
// DM'd, can earn loot, eats supplies, and can die.
|
||||||
|
func (s PartySeat) IsHuman() bool { return s.Kind != SeatCompanion }
|
||||||
|
|
||||||
|
// expeditionParty is THE answer to "who is on this expedition". Every other view
|
||||||
|
// — who gets mail, who sits down in a fight, who eats — is derived from it.
|
||||||
|
//
|
||||||
|
// It ALWAYS includes the owner. That is the whole point, and it is not a
|
||||||
|
// convenience: a solo expedition has **no expedition_party rows at all** (the
|
||||||
|
// roster only materializes on the first invite — see partyMembers), so any code
|
||||||
|
// that answers "who is in this party?" by reading the roster table gets *nobody*
|
||||||
|
// for a solo player, and then quietly falls back to whatever looked sensible at
|
||||||
|
// the call site.
|
||||||
|
//
|
||||||
|
// That is not hypothetical. It is exactly how the hired companion came out at
|
||||||
|
// **level 1** for every solo player — the one player the feature exists for. The
|
||||||
|
// level was averaged over the party; the party read as empty; the fallback was 1.
|
||||||
|
// He then walked into a tier-4 zone as a level-1 body, died on contact, and left
|
||||||
|
// the leader fighting a boss that had been inflated on his account. A 1500-run
|
||||||
|
// sweep is what found it, because nothing else could.
|
||||||
|
//
|
||||||
|
// So: there is no way to ask this function for the party and be handed an empty
|
||||||
|
// list. If the expedition exists, it has at least a leader.
|
||||||
|
func expeditionParty(expeditionID, ownerID string) ([]PartySeat, error) {
|
||||||
|
rows, err := partyMembers(expeditionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(members) == 0 {
|
if len(rows) == 0 {
|
||||||
return []id.UserID{id.UserID(ownerID)}, nil
|
// Solo: no roster rows. The owner IS the party.
|
||||||
|
if ownerID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []PartySeat{{UserID: id.UserID(ownerID), Kind: SeatLeader}}, nil
|
||||||
}
|
}
|
||||||
out := make([]id.UserID, 0, len(members))
|
out := make([]PartySeat, 0, len(rows))
|
||||||
for _, m := range members {
|
for _, m := range rows {
|
||||||
out = append(out, id.UserID(m.UserID))
|
kind := SeatMember
|
||||||
|
switch {
|
||||||
|
case isCompanionUser(m.UserID):
|
||||||
|
kind = SeatCompanion
|
||||||
|
case m.IsLeader():
|
||||||
|
kind = SeatLeader
|
||||||
|
}
|
||||||
|
out = append(out, PartySeat{UserID: id.UserID(m.UserID), Kind: kind})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// partySize is the number of seated players: 1 for a solo expedition.
|
// partyHumans is the party minus the companion: everyone with a person behind
|
||||||
|
// them. This is the set that gets mail, earns loot, eats supplies, and can die.
|
||||||
|
func partyHumans(expeditionID, ownerID string) ([]PartySeat, error) {
|
||||||
|
seats, err := expeditionParty(expeditionID, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := seats[:0]
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.IsHuman() {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// partyMemberIDs is the whole party as ids, leader first — every body, companion
|
||||||
|
// included. Callers that want only people want partyHumans.
|
||||||
|
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
|
||||||
|
seats, err := expeditionParty(expeditionID, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]id.UserID, 0, len(seats))
|
||||||
|
for _, s := range seats {
|
||||||
|
out = append(out, s.UserID)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// partySize is the number of seated *players*: 1 for a solo expedition.
|
||||||
|
//
|
||||||
|
// A hired companion is not counted, and both of this function's consumers want
|
||||||
|
// it that way:
|
||||||
|
//
|
||||||
|
// - expeditionBurnRatePct scales the daily supply burn by party size. Pete
|
||||||
|
// never bought a pack — members buy their own on !expedition accept, and he
|
||||||
|
// accepts nothing — so counting him would bill the leader a 60% higher burn
|
||||||
|
// for a mouth that brought its own rations. He is on expenses.
|
||||||
|
// - the "your party is still waiting on you" gate blocks a leader from
|
||||||
|
// starting a new run while an extracted party is pending. A roster holding
|
||||||
|
// nobody but Pete is not a party waiting on anyone, and counting him would
|
||||||
|
// lock the leader out of the game until they abandoned the run.
|
||||||
|
//
|
||||||
|
// The combat roster is a different question with a different answer: Pete IS a
|
||||||
|
// body in the fight, so CombatSession.RosterSize() counts him and the enemy-HP
|
||||||
|
// scalar feels him. Seats and mouths are not the same set.
|
||||||
func partySize(expeditionID string) (int, error) {
|
func partySize(expeditionID string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := db.Get().QueryRow(
|
err := db.Get().QueryRow(
|
||||||
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
|
`SELECT COUNT(*) FROM expedition_party
|
||||||
expeditionID).Scan(&n)
|
WHERE expedition_id = ? AND user_id <> ?`,
|
||||||
|
expeditionID, string(companionUserID())).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,10 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
|||||||
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
|
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
|
||||||
}
|
}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
|
if isCompanionUser(m.UserID) {
|
||||||
|
b.WriteString(companionRosterLine(exp.ID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
role := "member"
|
role := "member"
|
||||||
if m.IsLeader() {
|
if m.IsLeader() {
|
||||||
role = "leader"
|
role = "leader"
|
||||||
@@ -260,6 +264,9 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
|||||||
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
|
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
|
||||||
if inviteWindowOpen(exp) {
|
if inviteWindowOpen(exp) {
|
||||||
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
|
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
|
||||||
|
if !companionSeated(exp.ID) {
|
||||||
|
b.WriteString("\n_Short a body? `!expedition hire` brings Pete along._")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,13 +108,45 @@ func seatedExpeditionFor(userID id.UserID) (*Expedition, error) {
|
|||||||
// degrades to the owner rather than dropping the message — a player who misses
|
// degrades to the owner rather than dropping the message — a player who misses
|
||||||
// their briefing because SQLite hiccuped is a worse outcome than a member who
|
// their briefing because SQLite hiccuped is a worse outcome than a member who
|
||||||
// misses theirs.
|
// misses theirs.
|
||||||
|
//
|
||||||
|
// A hired companion is dropped here, and this is the chokepoint that keeps him
|
||||||
|
// out of every DM seam at once: the briefing, the recap, the digest, the
|
||||||
|
// extraction notice — and, crucially, the per-member side effects that ride the
|
||||||
|
// fan-out rather than the message. maybeRollPetArrivalOnEmerge would offer Pete
|
||||||
|
// a pet and park a pending interaction awaiting a reply that never comes;
|
||||||
|
// maybeFireAnchoredEvent would claim him a daily event slot and DM him a
|
||||||
|
// choice. He is not a person; he does not get mail. See isCompanionSeat.
|
||||||
func expeditionAudience(e *Expedition) []id.UserID {
|
func expeditionAudience(e *Expedition) []id.UserID {
|
||||||
|
if e == nil || e.UserID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seats, err := partyHumans(e.ID, e.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("expedition: party roster read failed, DMing owner only",
|
||||||
|
"expedition", e.ID, "err", err)
|
||||||
|
return []id.UserID{id.UserID(e.UserID)}
|
||||||
|
}
|
||||||
|
out := make([]id.UserID, 0, len(seats))
|
||||||
|
for _, s := range seats {
|
||||||
|
out = append(out, s.UserID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// expeditionSeats is every body that sits down in a fight: the whole roster,
|
||||||
|
// hired companion included. It is deliberately NOT expeditionAudience — that one
|
||||||
|
// drops the companion because he does not get mail, and a combat roster built
|
||||||
|
// from it would seat everyone the leader paid for except the one he paid for.
|
||||||
|
//
|
||||||
|
// Mail and seats are different sets. Anything that sends is an audience;
|
||||||
|
// anything that fights is a seat.
|
||||||
|
func expeditionSeats(e *Expedition) []id.UserID {
|
||||||
if e == nil || e.UserID == "" {
|
if e == nil || e.UserID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ids, err := partyMemberIDs(e.ID, e.UserID)
|
ids, err := partyMemberIDs(e.ID, e.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("expedition: party roster read failed, DMing owner only",
|
slog.Warn("expedition: party roster read failed, seating owner only",
|
||||||
"expedition", e.ID, "err", err)
|
"expedition", e.ID, "err", err)
|
||||||
return []id.UserID{id.UserID(e.UserID)}
|
return []id.UserID{id.UserID(e.UserID)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") ==
|
|||||||
type SimRunner struct {
|
type SimRunner struct {
|
||||||
P *AdventurePlugin
|
P *AdventurePlugin
|
||||||
Euro *EuroPlugin
|
Euro *EuroPlugin
|
||||||
|
// Companion hires Pete into the party on Day 1: "" = none, "auto" = fill the
|
||||||
|
// missing role, or an explicit class id. He takes a combat seat and no loot,
|
||||||
|
// which is exactly the thing the sweep is here to measure — whether an extra
|
||||||
|
// below-median body lifts the trailing case without carrying it.
|
||||||
|
Companion string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs
|
// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs
|
||||||
@@ -49,6 +54,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
|
|||||||
// etc. fire the way a competent prod player would set them up.
|
// etc. fire the way a competent prod player would set them up.
|
||||||
// Without this the sim under-counts class survival.
|
// Without this the sim under-counts class survival.
|
||||||
simAutoArmEnabled = true
|
simAutoArmEnabled = true
|
||||||
|
// N7/B3 — neutralize the weekly Omen so corpus results don't depend on which
|
||||||
|
// wall-clock week the sweep runs in (the sim's synthetic clock never reaches
|
||||||
|
// activeOmen's time.Now()).
|
||||||
|
simOmenDisabled = true
|
||||||
euro := &EuroPlugin{}
|
euro := &EuroPlugin{}
|
||||||
p := &AdventurePlugin{euro: euro}
|
p := &AdventurePlugin{euro: euro}
|
||||||
return &SimRunner{P: p, Euro: euro}, nil
|
return &SimRunner{P: p, Euro: euro}, nil
|
||||||
@@ -127,6 +136,38 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrepareRealCharacter readies an already-persisted character (loaded from a
|
||||||
|
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
|
||||||
|
// nothing: the character keeps its real race/class/subclass/level, ability
|
||||||
|
// scores, equipment, spellbook and inventory. It only (a) heals to full — a
|
||||||
|
// player rests before heading out, so we don't handicap a wounded prod snapshot
|
||||||
|
// — and (b) tops the bankroll up to `bank` so the "heavy" supply preset always
|
||||||
|
// affords itself regardless of the player's live coin balance. Returns the
|
||||||
|
// loaded character. The DB copy is disposable; the live prod file is untouched.
|
||||||
|
func (s *SimRunner) PrepareRealCharacter(uid id.UserID, bank float64) (*DnDCharacter, error) {
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("no character for %s in db copy", uid)
|
||||||
|
}
|
||||||
|
if c.Class == "" {
|
||||||
|
return nil, fmt.Errorf("%s has no class (setup incomplete) — nothing to simulate", uid)
|
||||||
|
}
|
||||||
|
c.HPCurrent = c.HPMax
|
||||||
|
c.TempHP = 0
|
||||||
|
c.Exhaustion = 0
|
||||||
|
c.ShortRestCharges = c.Level
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
|
||||||
|
}
|
||||||
|
if bal := s.Euro.GetBalance(uid); bal < bank {
|
||||||
|
s.Euro.Credit(uid, bank-bal, "expedition-sim real-char bankroll top-up")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
||||||
// + a couple offensive items into the synthetic player's inventory so
|
// + a couple offensive items into the synthetic player's inventory so
|
||||||
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
||||||
@@ -364,7 +405,42 @@ type SimCombatSummary struct {
|
|||||||
// SetSimIncludeTrace(true) has been called, and only on the LAST
|
// SetSimIncludeTrace(true) has been called, and only on the LAST
|
||||||
// combat per expedition (the boss room) to keep JSONL size bounded.
|
// combat per expedition (the boss room) to keep JSONL size bounded.
|
||||||
// Used by J2 caster-survival analysis.
|
// Used by J2 caster-survival analysis.
|
||||||
Events []CombatEvent `json:",omitempty"`
|
Events []simTraceEvent `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// simTraceEvent is CombatEvent with the seat ALWAYS emitted.
|
||||||
|
//
|
||||||
|
// CombatEvent.Seat is `omitempty` for wire-compat reasons that are correct for
|
||||||
|
// persistence and wrong for a trace: seat 0 and "no seat" serialize identically,
|
||||||
|
// so a party trace silently reads as a solo fight. That is not hypothetical — it
|
||||||
|
// is what made a hired companion who never took a turn look, in the JSON, like a
|
||||||
|
// fight that only ever had one seat, and it cost an hour of chasing the wrong bug.
|
||||||
|
// A diagnostic that cannot tell you who acted is not a diagnostic.
|
||||||
|
type simTraceEvent struct {
|
||||||
|
Round int
|
||||||
|
Seat int // always present, even when 0
|
||||||
|
Phase string
|
||||||
|
Actor string
|
||||||
|
Action string
|
||||||
|
Damage int
|
||||||
|
PlayerHP int
|
||||||
|
EnemyHP int
|
||||||
|
Roll int
|
||||||
|
RollAgainst int
|
||||||
|
Desc string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// simTraceEvents converts a TurnLog for the trace.
|
||||||
|
func simTraceEvents(events []CombatEvent) []simTraceEvent {
|
||||||
|
out := make([]simTraceEvent, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
out[i] = simTraceEvent{
|
||||||
|
Round: e.Round, Seat: e.Seat, Phase: e.Phase, Actor: e.Actor, Action: e.Action,
|
||||||
|
Damage: e.Damage, PlayerHP: e.PlayerHP, EnemyHP: e.EnemyHP,
|
||||||
|
Roll: e.Roll, RollAgainst: e.RollAgainst, Desc: e.Desc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// simIncludeTrace gates per-round event capture on SimCombatSummary.
|
// simIncludeTrace gates per-round event capture on SimCombatSummary.
|
||||||
@@ -480,6 +556,22 @@ func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneI
|
|||||||
return res, fmt.Errorf("expedition vanished while seating the party")
|
return res, fmt.Errorf("expedition vanished while seating the party")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Hire the companion on Day 1, after the humans are seated so the role fill
|
||||||
|
// sees the party it is filling a hole in. He is not in `members` — he buys no
|
||||||
|
// packs and takes no seat in the human roster — but fightRoster reads
|
||||||
|
// expeditionSeats, so he shows up in every fight from here on.
|
||||||
|
if s.Companion != "" {
|
||||||
|
class, ok := parseCompanionClass(s.Companion)
|
||||||
|
if !ok {
|
||||||
|
class = companionRoleFill(companionPartyClasses(exp.ID))
|
||||||
|
}
|
||||||
|
if err := hireCompanion(exp.ID, class, companionPartyLevel(exp.ID)); err != nil {
|
||||||
|
res.Outcome = "halted"
|
||||||
|
res.StopCode = "companion:" + err.Error()
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
roster := append([]id.UserID{uid}, members...)
|
roster := append([]id.UserID{uid}, members...)
|
||||||
res.PartySize = len(roster)
|
res.PartySize = len(roster)
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
@@ -906,7 +998,7 @@ func simCombatSummaries(uid id.UserID) []SimCombatSummary {
|
|||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
if simIncludeTrace && len(out) > 0 {
|
if simIncludeTrace && len(out) > 0 {
|
||||||
out[len(out)-1].Events = lastEvents
|
out[len(out)-1].Events = simTraceEvents(lastEvents)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1002,6 +1094,18 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
|
|||||||
turn.Sender = id.UserID(cur.seatUserID(seat))
|
turn.Sender = id.UserID(cur.seatUserID(seat))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An engine-driven seat is never dispatched as a chat command. There is no
|
||||||
|
// character for the handlers to load, and a command arriving from that
|
||||||
|
// seat's id reads to beginCombatTurn as a player returning to the keyboard
|
||||||
|
// — which is how the driver used to clear the very latch that was moving
|
||||||
|
// the seat. Drive it directly instead.
|
||||||
|
if cur.seatIsEngineDriven(seat) {
|
||||||
|
if err := p.driveEngineSeat(cur, seat); err != nil {
|
||||||
|
return false, fmt.Errorf("engine seat %d: %w", seat, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
|
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
|
||||||
var dispatchErr error
|
var dispatchErr error
|
||||||
switch kind {
|
switch kind {
|
||||||
@@ -1106,8 +1210,16 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio
|
|||||||
// which is seat 0. Driving a party member's turn off the leader's HP would heal
|
// which is seat 0. Driving a party member's turn off the leader's HP would heal
|
||||||
// the wrong person and re-arm the wrong aura.
|
// the wrong person and re-arm the wrong aura.
|
||||||
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
||||||
c, _ := LoadDnDCharacter(uid)
|
if sess == nil {
|
||||||
if c == nil || sess == nil {
|
return "attack", ""
|
||||||
|
}
|
||||||
|
// seatPickSheet, not LoadDnDCharacter: the hired companion has no character row
|
||||||
|
// and never will, so the raw load returned nil for him and this function bailed
|
||||||
|
// to "attack" on its first line — every turn, for the whole fight. That one line
|
||||||
|
// is why a hired Cleric swung a mace while the party died. He fights off the
|
||||||
|
// same synthetic sheet the rest of his seat is built from.
|
||||||
|
c := seatPickSheet(sess, uid)
|
||||||
|
if c == nil {
|
||||||
return "attack", ""
|
return "attack", ""
|
||||||
}
|
}
|
||||||
st := sess.actorStatusesForSeat(seat)
|
st := sess.actorStatusesForSeat(seat)
|
||||||
@@ -1121,6 +1233,20 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// §1 — a healer with a hurt friend heals the friend. This is what a competent
|
||||||
|
// player does, and until the engine could target another seat it was not an
|
||||||
|
// option the picker even had. It runs before the damage picks: a party member
|
||||||
|
// about to die is a more urgent use of a slot than another Fireball.
|
||||||
|
// A healer heals whoever is worst off — the friend bleeding out beside them, or
|
||||||
|
// themselves. An empty target is a self-cast, and `!cast <spell>` with no
|
||||||
|
// @mention is exactly how a player writes that.
|
||||||
|
if id, target := simPickHeal(c, uid, sess, seat); id != "" {
|
||||||
|
if target == "" {
|
||||||
|
return "cast", id
|
||||||
|
}
|
||||||
|
return "cast", id + " @" + target
|
||||||
|
}
|
||||||
|
|
||||||
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
|
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
|
||||||
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
||||||
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
||||||
@@ -1128,7 +1254,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
|||||||
// otherwise never spends an L2 slot on it. Force the pick once
|
// otherwise never spends an L2 slot on it. Force the pick once
|
||||||
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
||||||
// it's not a damage option.
|
// it's not a damage option.
|
||||||
if id := simPickSpiritualWeapon(c, uid, st); id != "" {
|
if id := simPickSpiritualWeapon(c, sess, seat, uid, st); id != "" {
|
||||||
return "cast", id
|
return "cast", id
|
||||||
}
|
}
|
||||||
// Once a concentration aura is up, a competent caster maintains it and
|
// Once a concentration aura is up, a competent caster maintains it and
|
||||||
@@ -1136,7 +1262,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
|||||||
// slot to re-arm the same aura — so the picker excludes concentration
|
// slot to re-arm the same aura — so the picker excludes concentration
|
||||||
// spells while one is active.
|
// spells while one is active.
|
||||||
auraActive := st.ConcentrationDmg > 0
|
auraActive := st.ConcentrationDmg > 0
|
||||||
if id := simPickSpell(c, uid, auraActive); id != "" {
|
if id := simPickSpell(c, sess, seat, uid, auraActive); id != "" {
|
||||||
return "cast", id
|
return "cast", id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1171,14 +1297,14 @@ func simMartialFirstClass(class DnDClass) bool {
|
|||||||
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
|
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
|
||||||
// not worth burning the bigger slot's damage potential elsewhere; sim
|
// not worth burning the bigger slot's damage potential elsewhere; sim
|
||||||
// behaves like a competent player and saves the high slot.
|
// behaves like a competent player and saves the high slot.
|
||||||
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string {
|
func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, st ActorStatuses) string {
|
||||||
if c == nil || c.Class != ClassCleric {
|
if c == nil || c.Class != ClassCleric {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if st.BuffSpiritProc > 0 {
|
if st.BuffSpiritProc > 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
known, err := listKnownSpells(uid)
|
known, err := seatKnownSpells(sess, seat, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -1192,7 +1318,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
|||||||
if !prepared {
|
if !prepared {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
slots, _ := getSpellSlots(uid)
|
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||||
const simMaxSlot = 5
|
const simMaxSlot = 5
|
||||||
for sl := 2; sl <= simMaxSlot; sl++ {
|
for sl := 2; sl <= simMaxSlot; sl++ {
|
||||||
pair, ok := slots[sl]
|
pair, ok := slots[sl]
|
||||||
@@ -1225,12 +1351,111 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
|||||||
// - Among feasible candidates, prefer higher slot level (preserves
|
// - Among feasible candidates, prefer higher slot level (preserves
|
||||||
// high-slot supremacy and burns the big slots first); tie-break on
|
// high-slot supremacy and burns the big slots first); tie-break on
|
||||||
// expected damage from the dice string.
|
// expected damage from the dice string.
|
||||||
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
|
//
|
||||||
known, err := listKnownSpells(uid)
|
// simHealAllyThresholdPct is how hurt a friend has to be before a healer spends a
|
||||||
|
// slot on them rather than on damage. Deliberately lower than the self-heal
|
||||||
|
// threshold: a heal is a wasted turn if the target was going to live anyway, and
|
||||||
|
// the party's damage output is what ends the fight.
|
||||||
|
const simHealAllyThresholdPct = 45
|
||||||
|
|
||||||
|
// simPickHeal returns the heal spell a competent healer would cast this turn, and
|
||||||
|
// the seat to put it on — which may be the healer's own. An empty spell id means
|
||||||
|
// nobody needs healing, or this character cannot heal.
|
||||||
|
//
|
||||||
|
// Returning target "" means "cast it on yourself": the caller drops the @mention,
|
||||||
|
// and the engine's ordinary self-heal path takes it.
|
||||||
|
//
|
||||||
|
// It considers the caster's own seat, and it runs for a solo fight, and BOTH of
|
||||||
|
// those are recent. Until now the rule skipped `i == seat` and bailed on
|
||||||
|
// `!sess.IsParty()`, which together meant something nobody had said out loud: **no
|
||||||
|
// autopiloted caster in this game had ever cast a heal on themselves.** A solo
|
||||||
|
// cleric carried cure_wounds for a whole 30-room run and used it exactly never,
|
||||||
|
// while dying with a full pool of level-1 slots. That is a strong candidate for
|
||||||
|
// why the class corpus has cleric at 46–56% against fighter's 82%
|
||||||
|
// ([[project_d8prereq_baseline]], §6 of the combat-engine plan) — the picker was
|
||||||
|
// not playing the class.
|
||||||
|
//
|
||||||
|
// The healer heals whoever is worst off. Usually that is the friend bleeding out
|
||||||
|
// next to them. Sometimes it is them.
|
||||||
|
func simPickHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
|
||||||
|
if sess == nil || c == nil || !isSpellcaster(c) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Who is worst off? Only living seats — the engine will not raise the downed,
|
||||||
|
// and a heal aimed at a corpse is a lost turn.
|
||||||
|
worst, worstPct := -1, 101
|
||||||
|
for i := range sess.RosterSize() {
|
||||||
|
hp, hpMax := sess.seatHP(i), sess.seatHPMax(i)
|
||||||
|
if hp <= 0 || hpMax <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pct := hp * 100 / hpMax; pct < worstPct {
|
||||||
|
worst, worstPct = i, pct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worst < 0 || worstPct >= simHealAllyThresholdPct {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
// Healing yourself is not an ally-target at all — it is the plain self-heal the
|
||||||
|
// engine has always had. Hand the caller no target and let it say so.
|
||||||
|
if worst == seat {
|
||||||
|
return simPickHealSpell(c, uid, sess, seat), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
best := simPickHealSpell(c, uid, sess, seat)
|
||||||
|
if best == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
|
||||||
|
// the seats in this fight, so this round-trips without a room lookup.
|
||||||
|
return best, id.UserID(sess.seatUserID(worst)).Localpart()
|
||||||
|
}
|
||||||
|
|
||||||
|
// simPickHealSpell is the cheapest heal this caster can actually cast right now,
|
||||||
|
// or "". Burning a 5th-level slot to top somebody up is not what a competent
|
||||||
|
// player does, so it takes the lowest castable slot.
|
||||||
|
func simPickHealSpell(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) string {
|
||||||
|
known, err := seatKnownSpells(sess, seat, uid)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||||
|
best, bestLevel := "", 99
|
||||||
|
for _, k := range known {
|
||||||
|
if !k.Prepared {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp, ok := lookupSpell(k.SpellID)
|
||||||
|
if !ok || sp.Effect != EffectSpellHeal || sp.CastTime == CastReaction {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
onList := false
|
||||||
|
for _, cl := range sp.Classes {
|
||||||
|
if cl == c.Class {
|
||||||
|
onList = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !onList || sp.Level == 0 {
|
||||||
|
continue // heals are slot spells; a level-0 "heal" is not a thing we cast
|
||||||
|
}
|
||||||
|
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sp.Level < bestLevel {
|
||||||
|
best, bestLevel = sp.ID, sp.Level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {
|
||||||
|
known, err := seatKnownSpells(sess, seat, uid)
|
||||||
if err != nil || len(known) == 0 {
|
if err != nil || len(known) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
slots, _ := getSpellSlots(uid)
|
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||||
type cand struct {
|
type cand struct {
|
||||||
id string
|
id string
|
||||||
slot int
|
slot int
|
||||||
|
|||||||
303
internal/plugin/pete.go
Normal file
303
internal/plugin/pete.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file is gogobee's game-side entry to Pete's adventure news. Plugins call
|
||||||
|
// emitFact at event chokepoints; it enforces the kill-switch + per-player
|
||||||
|
// opt-out, then hands a durable fact to peteclient. gogobee supplies facts only
|
||||||
|
// — Pete owns the voice. See pete_adventure_news_plan.md / _voice.md.
|
||||||
|
|
||||||
|
const (
|
||||||
|
newsEnabledKey = "adventure_news_enabled"
|
||||||
|
newsGUIDSaltKey = "adventure_news_guid_salt"
|
||||||
|
anonName = "an adventurer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newsEmissionOn is the runtime kill-switch (default on). An operator flips it
|
||||||
|
// with `!news off` without a redeploy; FEATURE_PETE_NEWS is the source-level
|
||||||
|
// master switch checked separately by peteclient.Enabled. Persisted in the
|
||||||
|
// durable news_config table (NOT api_cache, which RunMaintenance prunes).
|
||||||
|
func newsEmissionOn() bool {
|
||||||
|
var v string
|
||||||
|
err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsEnabledKey).Scan(&v)
|
||||||
|
if err != nil {
|
||||||
|
return true // no row → default on
|
||||||
|
}
|
||||||
|
return v != "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNewsEmission persists the runtime kill-switch.
|
||||||
|
func setNewsEmission(on bool) {
|
||||||
|
v := "1"
|
||||||
|
if !on {
|
||||||
|
v = "0"
|
||||||
|
}
|
||||||
|
db.Exec("news emission set",
|
||||||
|
`INSERT INTO news_config (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
newsEnabledKey, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNewsOptedOut reports whether a player asked not to be named in the news.
|
||||||
|
func isNewsOptedOut(userID id.UserID) bool {
|
||||||
|
var one int
|
||||||
|
err := db.Get().QueryRow(`SELECT 1 FROM news_optout WHERE user_id = ?`, string(userID)).Scan(&one)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNewsOptout records or clears a player's opt-out. Opting out anonymizes
|
||||||
|
// their name in future dispatches ("an adventurer…"); it does not stop events
|
||||||
|
// being reported. Idempotent either direction.
|
||||||
|
func setNewsOptout(userID id.UserID, out bool) {
|
||||||
|
if out {
|
||||||
|
db.Exec("news optout set",
|
||||||
|
`INSERT OR IGNORE INTO news_optout (user_id, opted_out_at) VALUES (?, unixepoch())`,
|
||||||
|
string(userID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Exec("news optout clear", `DELETE FROM news_optout WHERE user_id = ?`, string(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
guidSaltOnce sync.Once
|
||||||
|
guidSalt []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
// newsGUIDSalt returns the secret, server-side salt that keys every public event
|
||||||
|
// token. It is 32 random bytes, generated once and persisted in the durable
|
||||||
|
// news_config table, cached in-process. Secret from anyone outside the server
|
||||||
|
// (external users never see it), stable across restarts (so a re-emit or the
|
||||||
|
// cold-start backfill reproduces the same GUID). No operator action needed.
|
||||||
|
func newsGUIDSalt() []byte {
|
||||||
|
guidSaltOnce.Do(func() {
|
||||||
|
var stored string
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&stored); err == nil {
|
||||||
|
if b, e := hex.DecodeString(stored); e == nil && len(b) > 0 {
|
||||||
|
guidSalt = b
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// crypto/rand should never fail; if it does, refuse to fall back to a
|
||||||
|
// predictable salt (that would reopen the enumeration attack). Leave the
|
||||||
|
// salt empty and let eventToken sort it out.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
salt := hex.EncodeToString(b)
|
||||||
|
// Persist. OR IGNORE so a concurrent first-writer wins cleanly; we then
|
||||||
|
// re-read the row so every caller agrees on the same salt.
|
||||||
|
db.Exec("news guid salt seed",
|
||||||
|
`INSERT OR IGNORE INTO news_config (key, value) VALUES (?, ?)`, newsGUIDSaltKey, salt)
|
||||||
|
_ = db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt)
|
||||||
|
guidSalt, _ = hex.DecodeString(salt)
|
||||||
|
})
|
||||||
|
return guidSalt
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventToken is the per-event, one-way identifier embedded in a public GUID
|
||||||
|
// (which becomes a PUBLIC permalink path on Pete). It is HMAC-SHA256 over the
|
||||||
|
// Matrix user id AND an event-specific discriminator, keyed by the secret
|
||||||
|
// newsGUIDSalt.
|
||||||
|
//
|
||||||
|
// Two properties matter and both come from HMAC being a PRF under a secret key:
|
||||||
|
// - Not computable from a Matrix handle. An attacker who knows @alice:server
|
||||||
|
// cannot derive any of her tokens without the salt — so her events (including
|
||||||
|
// ones anonymized after `!news optout`) can't be enumerated from her handle.
|
||||||
|
// - Per-event, so tokens are mutually unlinkable. Even for the same user, two
|
||||||
|
// events yield unrelated tokens; a story that names her (opted-in, or a duel
|
||||||
|
// opponent) exposes only that one event's token and can't be walked back to
|
||||||
|
// her other, anonymized events.
|
||||||
|
//
|
||||||
|
// The discriminator must uniquely identify the logical event so a re-emit or the
|
||||||
|
// backfill reproduces the same token (idempotency rides on the GUID).
|
||||||
|
func eventToken(userID id.UserID, discriminator string) string {
|
||||||
|
mac := hmac.New(sha256.New, newsGUIDSalt())
|
||||||
|
mac.Write([]byte(userID))
|
||||||
|
mac.Write([]byte{0}) // domain separator: keep id and discriminator from bleeding
|
||||||
|
mac.Write([]byte(discriminator))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil)[:9]) // 18 hex chars — ample, non-reversible
|
||||||
|
}
|
||||||
|
|
||||||
|
// charName returns a player's in-game character name, never their Matrix handle.
|
||||||
|
// Empty when unknown — callers skip the emit rather than fall back to a handle.
|
||||||
|
func charName(userID id.UserID) string {
|
||||||
|
name, _ := loadDisplayName(userID)
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// charLevel returns a player's current character level for a news Fact, or 0 if
|
||||||
|
// no character loads (Level is omitempty, so 0 is dropped from the payload).
|
||||||
|
func charLevel(userID id.UserID) int {
|
||||||
|
if dc, _ := LoadDnDCharacter(userID); dc != nil {
|
||||||
|
return dc.Level
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// classRaceLabel renders a character's race + class for the arrival fact, e.g.
|
||||||
|
// "Elf Ranger".
|
||||||
|
func classRaceLabel(c *DnDCharacter) string {
|
||||||
|
ri, _ := raceInfo(c.Race)
|
||||||
|
ci, _ := classInfo(c.Class)
|
||||||
|
return strings.TrimSpace(ri.Display + " " + ci.Display)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitFact is the single game-side entry to Pete's news. It enforces the runtime
|
||||||
|
// kill-switch and per-player opt-out (anonymize the name, never drop the event),
|
||||||
|
// derives the actors allow-list from the FINAL names (so Pete's fact-guard and
|
||||||
|
// the rendered content agree), then durably queues the fact.
|
||||||
|
//
|
||||||
|
// subjectUser/opponentUser are the Matrix users behind Subject/Opponent (either
|
||||||
|
// may be empty for realm-level events). They are used ONLY for the opt-out
|
||||||
|
// lookup and are never sent.
|
||||||
|
func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) {
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subjectUser != "" && isNewsOptedOut(subjectUser) {
|
||||||
|
f.Subject = anonName
|
||||||
|
}
|
||||||
|
if opponentUser != "" && isNewsOptedOut(opponentUser) {
|
||||||
|
f.Opponent = anonName
|
||||||
|
}
|
||||||
|
var actors []string
|
||||||
|
for _, n := range []string{f.Subject, f.Opponent} {
|
||||||
|
if n != "" {
|
||||||
|
actors = append(actors, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Actors = actors
|
||||||
|
peteclient.Emit(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNewsCmd backs `!news`. Players toggle whether they're named in Pete's
|
||||||
|
// dispatches; admins flip the realm-wide emission kill-switch. Replies in-room
|
||||||
|
// so the effect is visible where it was invoked.
|
||||||
|
//
|
||||||
|
// !news → show your status + what the command does
|
||||||
|
// !news optout → anonymize your name in future dispatches
|
||||||
|
// !news optin → be named again
|
||||||
|
// !news on|off → (admin) master kill-switch for all emission
|
||||||
|
func (p *AdventurePlugin) handleNewsCmd(ctx MessageContext) error {
|
||||||
|
arg := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "news")))
|
||||||
|
switch arg {
|
||||||
|
case "optout", "opt-out", "anonymize", "anon":
|
||||||
|
setNewsOptout(ctx.Sender, true)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Done — Pete will refer to you as \"an adventurer\" in the news from now on. `!news optin` to be named again.")
|
||||||
|
case "optin", "opt-in":
|
||||||
|
setNewsOptout(ctx.Sender, false)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"You're back on the record — Pete will use your character name in future dispatches.")
|
||||||
|
case "on", "enable":
|
||||||
|
if !p.IsAdmin(ctx.Sender) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
setNewsEmission(true)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "📣 Adventure news emission is now ON.")
|
||||||
|
case "off", "disable":
|
||||||
|
if !p.IsAdmin(ctx.Sender) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
setNewsEmission(false)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "🔇 Adventure news emission is now OFF. No new dispatches will be sent.")
|
||||||
|
case "", "status", "help":
|
||||||
|
named := "using your character name"
|
||||||
|
if isNewsOptedOut(ctx.Sender) {
|
||||||
|
named = "anonymized (\"an adventurer\")"
|
||||||
|
}
|
||||||
|
msg := "📰 **Pete's Adventure News** reports realm happenings — deaths, first-clears, arrivals, duels.\n" +
|
||||||
|
"You are currently " + named + ".\n" +
|
||||||
|
"`!news optout` to be anonymized · `!news optin` to be named again."
|
||||||
|
if p.IsAdmin(ctx.Sender) {
|
||||||
|
state := "ON"
|
||||||
|
if !newsEmissionOn() {
|
||||||
|
state = "OFF"
|
||||||
|
}
|
||||||
|
msg += "\n_(admin)_ emission is **" + state + "** · `!news on` / `!news off` to toggle."
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
default:
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown option. Try `!news`, `!news optout`, or `!news optin`.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nowUnix is the event timestamp helper (kept in one place so the emit sites
|
||||||
|
// read uniformly).
|
||||||
|
func nowUnix() int64 { return time.Now().Unix() }
|
||||||
|
|
||||||
|
// claimRealmFirst records the first occurrence of a (kind,target) across the
|
||||||
|
// realm and reports whether THIS call was the first — true exactly once. Used to
|
||||||
|
// tier realm-firsts (PRIORITY) apart from repeat clears (BULLETIN). The backfill
|
||||||
|
// pre-seeds this ledger so historical firsts aren't re-announced after a deploy.
|
||||||
|
func claimRealmFirst(kind, target string) bool {
|
||||||
|
res := db.ExecResult("news realm-first claim",
|
||||||
|
`INSERT OR IGNORE INTO news_realm_firsts (kind, target, first_at) VALUES (?, ?, unixepoch())`,
|
||||||
|
kind, target)
|
||||||
|
if res == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return err == nil && n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The
|
||||||
|
// realm's first clear of a zone is a PRIORITY "zone_first"; later clears are a
|
||||||
|
// BULLETIN "zone_clear". The event_type and the GUID prefix track the tier so
|
||||||
|
// Pete templates and labels a repeat as a repeat, not a first-ever. Character
|
||||||
|
// name only; no-op unless the seam is enabled.
|
||||||
|
func emitZoneClearNews(userID id.UserID, exp *Expedition) {
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Claim the realm-first BEFORE the name guard, so an unnamed straggler's
|
||||||
|
// genuine first clear still seeds news_realm_firsts. Otherwise the next
|
||||||
|
// named clearer would claim it and be mis-announced as the first-ever.
|
||||||
|
// Mirrors backfillZoneFirsts, which claims before its own name check.
|
||||||
|
eventType, tier := "zone_clear", "bulletin"
|
||||||
|
if claimRealmFirst("zone", string(exp.ZoneID)) {
|
||||||
|
eventType, tier = "zone_first", "priority"
|
||||||
|
}
|
||||||
|
name := charName(userID)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zone := zoneOrFallback(exp.ZoneID)
|
||||||
|
region := ""
|
||||||
|
if IsMultiRegionZone(exp.ZoneID) {
|
||||||
|
if r, ok := CurrentRegion(exp); ok {
|
||||||
|
region = r.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lvl := charLevel(userID)
|
||||||
|
ts := nowUnix()
|
||||||
|
disc := fmt.Sprintf("%s:%d", exp.ZoneID, ts)
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("%s:%s:%s:%d", eventType, eventToken(userID, disc), exp.ZoneID, ts),
|
||||||
|
EventType: eventType,
|
||||||
|
Tier: tier,
|
||||||
|
Subject: name,
|
||||||
|
Zone: zone.Display,
|
||||||
|
Region: region,
|
||||||
|
Boss: zone.Boss.Name,
|
||||||
|
Level: lvl,
|
||||||
|
Outcome: "cleared",
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, userID, "")
|
||||||
|
}
|
||||||
162
internal/plugin/pete_retreat_test.go
Normal file
162
internal/plugin/pete_retreat_test.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The retreat bulletin: the news can finally say "it went badly and nobody
|
||||||
|
// died". Before this, every event type Pete could file was a win, a death, or an
|
||||||
|
// introduction — so a run that simply fell apart was reported as nothing at all,
|
||||||
|
// and a class that retreated a third of the time just looked absent from the
|
||||||
|
// feed.
|
||||||
|
//
|
||||||
|
// The reason gate is the whole safety property. A death must not ALSO be filed
|
||||||
|
// as a retreat (it already files its own priority dispatch), and an idle reap
|
||||||
|
// must not be filed at all — Pete telling the realm that a named player was
|
||||||
|
// driven from the field, when in truth they closed their laptop, is a lie about
|
||||||
|
// a person by name.
|
||||||
|
|
||||||
|
func retreatFactFor(t *testing.T, uid id.UserID) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var payload string
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT payload FROM pete_emit_queue WHERE guid LIKE 'retreat:%'`).Scan(&payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("no retreat fact queued: %v", err)
|
||||||
|
}
|
||||||
|
var f map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetreatNews_ReasonGate(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
reason string
|
||||||
|
want int
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{lossCombatRetreat, 1, "a solo walker who ran out the clock and withdrew is news"},
|
||||||
|
{lossCombatFlee, 1, "a party that broke off is news"},
|
||||||
|
{lossCombatDeath, 0, "a death already files its own priority dispatch — do not double-report it as a retreat"},
|
||||||
|
{lossIdleTimeout, 0, "an idle reap is not a retreat; the player closed their laptop, they did not flee"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.reason, func(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
uid := id.UserID("@retreat:example.org")
|
||||||
|
zoneCmdTestCharacter(t, uid, 10)
|
||||||
|
|
||||||
|
emitRetreatNews(uid, tc.reason, ZoneID("underforge"), 3)
|
||||||
|
|
||||||
|
if got := queuedCount(t, "retreat:%"); got != tc.want {
|
||||||
|
t.Fatalf("reason %q queued %d retreat facts, want %d — %s",
|
||||||
|
tc.reason, got, tc.want, tc.why)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bulletin has to carry enough for Pete to write a sentence: who, where, how
|
||||||
|
// far they got. A retreat with no day count is just "someone left".
|
||||||
|
func TestRetreatNews_CarriesTheStory(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
uid := id.UserID("@retreat-story:example.org")
|
||||||
|
zoneCmdTestCharacter(t, uid, 10)
|
||||||
|
|
||||||
|
emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 3)
|
||||||
|
|
||||||
|
f := retreatFactFor(t, uid)
|
||||||
|
if f["event_type"] != "retreat" {
|
||||||
|
t.Errorf("event_type = %v, want retreat", f["event_type"])
|
||||||
|
}
|
||||||
|
// Bulletin, not priority: a retreat is a bad day, not a funeral.
|
||||||
|
if f["tier"] != "bulletin" {
|
||||||
|
t.Errorf("tier = %v, want bulletin", f["tier"])
|
||||||
|
}
|
||||||
|
if f["outcome"] != "retreated" {
|
||||||
|
t.Errorf("outcome = %v, want retreated", f["outcome"])
|
||||||
|
}
|
||||||
|
if f["count"] != float64(3) {
|
||||||
|
t.Errorf("count = %v, want 3 (the day it fell apart)", f["count"])
|
||||||
|
}
|
||||||
|
if f["zone"] == nil || f["zone"] == "" {
|
||||||
|
t.Error("no zone — Pete cannot say where it happened")
|
||||||
|
}
|
||||||
|
if f["subject"] == nil || f["subject"] == "" {
|
||||||
|
t.Error("no subject — Pete cannot say who it happened to")
|
||||||
|
}
|
||||||
|
// GUID prefix must equal the event type; Pete's taxonomy keys off it.
|
||||||
|
if guid, _ := f["guid"].(string); len(guid) < 8 || guid[:8] != "retreat:" {
|
||||||
|
t.Errorf("guid %q must be prefixed with the event type", guid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wiring, not just the emitter. Everything above calls emitRetreatNews
|
||||||
|
// directly, which proves nothing about the game: the fact only ships if the real
|
||||||
|
// run-loss chokepoint actually calls it, on a real expedition, and reads the day
|
||||||
|
// count BEFORE forcedExtractExpedition stamps the row 'abandoned' and takes the
|
||||||
|
// live fields with it. Drive the chokepoint.
|
||||||
|
func TestRetreatNews_WiredToTheRunLossChokepoint(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
uid := id.UserID("@retreat-wired:example")
|
||||||
|
campTestCharacter(t, uid, 1)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
forceExtractExpeditionForRunLoss(uid, lossCombatRetreat)
|
||||||
|
|
||||||
|
if got := queuedCount(t, "retreat:%"); got != 1 {
|
||||||
|
t.Fatalf("the run-loss chokepoint queued %d retreat facts, want 1 — "+
|
||||||
|
"emitRetreatNews passes its own unit tests but is not actually wired to the game", got)
|
||||||
|
}
|
||||||
|
f := retreatFactFor(t, uid)
|
||||||
|
// The day must survive the extract. Read it after forcedExtractExpedition and
|
||||||
|
// it is gone — the row is 'abandoned' and the live fields are zeroed.
|
||||||
|
if f["count"] != float64(exp.CurrentDay) {
|
||||||
|
t.Errorf("count = %v, want %d — the day count was read after the extract wiped it",
|
||||||
|
f["count"], exp.CurrentDay)
|
||||||
|
}
|
||||||
|
// And the expedition really did end; a bulletin about a run still in progress
|
||||||
|
// would be worse than no bulletin.
|
||||||
|
if still, _ := getActiveExpedition(uid); still != nil {
|
||||||
|
t.Error("expedition still active after a run-loss extract")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The opt-out covers the new event type too. A player who asked not to be named
|
||||||
|
// must not be named when they lose — that is precisely when they'd mind most.
|
||||||
|
func TestRetreatNews_HonoursOptOut(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
uid := id.UserID("@retreat-shy:example.org")
|
||||||
|
zoneCmdTestCharacter(t, uid, 10)
|
||||||
|
setNewsOptout(uid, true)
|
||||||
|
|
||||||
|
emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 2)
|
||||||
|
|
||||||
|
f := retreatFactFor(t, uid)
|
||||||
|
if f["subject"] != anonName {
|
||||||
|
t.Fatalf("subject = %v, want %q — the opt-out must cover retreats", f["subject"], anonName)
|
||||||
|
}
|
||||||
|
actors, _ := f["actors"].([]any)
|
||||||
|
for _, a := range actors {
|
||||||
|
if a != anonName {
|
||||||
|
t.Fatalf("actors leaked %v past the opt-out", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
internal/plugin/pete_test.go
Normal file
216
internal/plugin/pete_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// enablePeteSeam turns on the emit seam for a test and restores it to disabled
|
||||||
|
// afterwards, so a leaked-enabled singleton can't affect other tests.
|
||||||
|
func enablePeteSeam(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
os.Setenv("FEATURE_PETE_NEWS", "true")
|
||||||
|
os.Setenv("PETE_INGEST_URL", "http://127.0.0.1:0")
|
||||||
|
os.Setenv("PETE_INGEST_TOKEN", "tok")
|
||||||
|
peteclient.Init()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Unsetenv("FEATURE_PETE_NEWS")
|
||||||
|
os.Unsetenv("PETE_INGEST_URL")
|
||||||
|
os.Unsetenv("PETE_INGEST_TOKEN")
|
||||||
|
peteclient.Init()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func queuedCount(t *testing.T, likeGUID string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM pete_emit_queue WHERE guid LIKE ?`, likeGUID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("queue count: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewsOptoutRoundTrip: opting out records the player, opting in clears them,
|
||||||
|
// both idempotently.
|
||||||
|
func TestNewsOptoutRoundTrip(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)
|
||||||
|
|
||||||
|
u := id.UserID("@zapp:example.org")
|
||||||
|
if isNewsOptedOut(u) {
|
||||||
|
t.Fatal("fresh player should not be opted out")
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewsOptout(u, true)
|
||||||
|
if !isNewsOptedOut(u) {
|
||||||
|
t.Error("opt-out not recorded")
|
||||||
|
}
|
||||||
|
setNewsOptout(u, true) // idempotent
|
||||||
|
if !isNewsOptedOut(u) {
|
||||||
|
t.Error("second opt-out flipped state")
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewsOptout(u, false)
|
||||||
|
if isNewsOptedOut(u) {
|
||||||
|
t.Error("opt-in did not clear")
|
||||||
|
}
|
||||||
|
setNewsOptout(u, false) // idempotent
|
||||||
|
if isNewsOptedOut(u) {
|
||||||
|
t.Error("second opt-in flipped state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPeteNewsBackfill: the one-shot replays deaths + solo achievements + zone
|
||||||
|
// realm-firsts through the emit queue, seeds the realm-first ledger, and is
|
||||||
|
// idempotent on a second boot.
|
||||||
|
func TestPeteNewsBackfill(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)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
|
||||||
|
// A named, dead adventurer.
|
||||||
|
db.Exec("seed death", `INSERT INTO player_meta (user_id, display_name, last_death_date, death_location)
|
||||||
|
VALUES (?, ?, ?, ?)`, "@zapp:x", "Zapp", "2026-06-01", "the Underforge")
|
||||||
|
// A single-holder achievement.
|
||||||
|
db.Exec("seed achv", `INSERT INTO achievements (user_id, achievement_id, unlocked_at)
|
||||||
|
VALUES (?, ?, ?)`, "@zapp:x", "adv_first_blood", 1717200000)
|
||||||
|
// A completed, boss-defeated zone run → realm-first.
|
||||||
|
db.Exec("seed run", `INSERT INTO dnd_zone_run (run_id, user_id, zone_id, total_rooms, boss_defeated, completed_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, ?)`, "r1", "@zapp:x", "goblin_warren", 5, "2026-05-20 12:00:00")
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
p.bootstrapPeteNewsBackfill()
|
||||||
|
|
||||||
|
if got := queuedCount(t, "death:%"); got != 1 {
|
||||||
|
t.Errorf("death dispatches queued = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := queuedCount(t, "milestone:%"); got != 1 {
|
||||||
|
t.Errorf("achievement dispatches queued = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
||||||
|
t.Errorf("zone-first dispatches queued = %d, want 1", got)
|
||||||
|
}
|
||||||
|
// Realm-first ledger seeded so a later live clear tiers as a repeat.
|
||||||
|
if claimRealmFirst("zone", "goblin_warren") {
|
||||||
|
t.Error("realm-first ledger not seeded — a live clear would mis-announce as first-ever")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second boot: job gate holds, nothing re-queued.
|
||||||
|
p.bootstrapPeteNewsBackfill()
|
||||||
|
if got := queuedCount(t, "%"); got != 3 {
|
||||||
|
t.Errorf("total queued after re-run = %d, want 3 (idempotent)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventToken: the public GUID token is salted (not a bare sha256 an attacker
|
||||||
|
// can recompute from the handle), stable per logical event (idempotency), and
|
||||||
|
// per-event so a user's tokens don't link to each other.
|
||||||
|
func TestEventToken(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)
|
||||||
|
|
||||||
|
// The salt is cached process-wide via sync.Once; reset it so it derives (and
|
||||||
|
// persists) against this test's fresh DB rather than an earlier test's.
|
||||||
|
guidSaltOnce = sync.Once{}
|
||||||
|
guidSalt = nil
|
||||||
|
|
||||||
|
u := id.UserID("@alice:example.org")
|
||||||
|
|
||||||
|
// Not the old unsalted digest: an attacker with the handle can't recompute it.
|
||||||
|
bare := sha256.Sum256([]byte(u))
|
||||||
|
if got := eventToken(u, "arrival"); got == hex.EncodeToString(bare[:9]) {
|
||||||
|
t.Error("token matches unsalted sha256(handle) — enumeration attack reopened")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable for the same (user, discriminator): a re-emit dedups.
|
||||||
|
if eventToken(u, "arrival") != eventToken(u, "arrival") {
|
||||||
|
t.Error("token not stable for the same event — idempotency broken")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-event: two different events for the same user yield unrelated tokens, so
|
||||||
|
// a named event can't be walked back to the user's anonymized events.
|
||||||
|
if eventToken(u, "arrival") == eventToken(u, "death:123") {
|
||||||
|
t.Error("tokens collide across events — linkage attack reopened")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different users, same discriminator, still differ.
|
||||||
|
if eventToken(u, "arrival") == eventToken(id.UserID("@bob:example.org"), "arrival") {
|
||||||
|
t.Error("token collides across users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The salt persisted, so it survives a restart (a re-emit after reboot dedups).
|
||||||
|
var salt string
|
||||||
|
if err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt); err != nil || salt == "" {
|
||||||
|
t.Fatalf("guid salt not persisted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitZoneClearTaxonomy: the realm's first clear of a zone emits a PRIORITY
|
||||||
|
// zone_first; a later clear emits a BULLETIN zone_clear — distinct event_type and
|
||||||
|
// GUID prefix, so Pete never files a repeat as a first-ever.
|
||||||
|
func TestEmitZoneClearTaxonomy(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)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
|
||||||
|
db.Exec("seed player", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`,
|
||||||
|
"@zapp:x", "Zapp")
|
||||||
|
exp := &Expedition{ZoneID: "goblin_warren"}
|
||||||
|
|
||||||
|
emitZoneClearNews(id.UserID("@zapp:x"), exp) // realm-first
|
||||||
|
emitZoneClearNews(id.UserID("@zapp:x"), exp) // repeat
|
||||||
|
|
||||||
|
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
||||||
|
t.Errorf("zone_first queued = %d, want 1 (the realm-first)", got)
|
||||||
|
}
|
||||||
|
if got := queuedCount(t, "zone_clear:%"); got != 1 {
|
||||||
|
t.Errorf("zone_clear queued = %d, want 1 (the repeat)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
|
||||||
|
func TestNewsEmissionKillSwitch(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)
|
||||||
|
|
||||||
|
if !newsEmissionOn() {
|
||||||
|
t.Error("emission should default ON")
|
||||||
|
}
|
||||||
|
setNewsEmission(false)
|
||||||
|
if newsEmissionOn() {
|
||||||
|
t.Error("emission still ON after disable")
|
||||||
|
}
|
||||||
|
setNewsEmission(true)
|
||||||
|
if !newsEmissionOn() {
|
||||||
|
t.Error("emission still OFF after enable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1349,6 +1349,9 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
|
|||||||
if cleared, err := loadEpilogueCleared(uid); err == nil {
|
if cleared, err := loadEpilogueCleared(uid); err == nil {
|
||||||
c.EpilogueCleared = cleared
|
c.EpilogueCleared = cleared
|
||||||
}
|
}
|
||||||
|
if xp, err := loadRenownXP(uid); err == nil {
|
||||||
|
c.RenownXP = xp
|
||||||
|
}
|
||||||
if s, err := loadHouseState(uid); err == nil {
|
if s, err := loadHouseState(uid); err == nil {
|
||||||
c.HouseTier = s.Tier
|
c.HouseTier = s.Tier
|
||||||
c.HouseLoanBalance = s.LoanBalance
|
c.HouseLoanBalance = s.LoanBalance
|
||||||
|
|||||||
81
internal/plugin/player_meta_seed_test.go
Normal file
81
internal/plugin/player_meta_seed_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAutoMigrationSeedsPlayerMeta guards the camcast straggler regression: a
|
||||||
|
// brand-new player whose first-ever adventure action auto-migrates a character
|
||||||
|
// (e.g. !rest before !setup) must come out with a canonical player_meta seed,
|
||||||
|
// or every legacy-layer command (expeditions, arena, world boss, town…) fails
|
||||||
|
// with "sql: no rows". Before the ensurePlayerMetaSeed guard this produced a
|
||||||
|
// confirmed dnd_character with zero player_meta rows.
|
||||||
|
func TestAutoMigrationSeedsPlayerMeta(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@fresh-automigrant:example.org")
|
||||||
|
|
||||||
|
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||||
|
p.Sink = &captureSink{}
|
||||||
|
|
||||||
|
// First-ever adventure action for a player with no character at all.
|
||||||
|
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
|
||||||
|
t.Fatalf("handleDnDShortRest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var dndRows, pmRows, equipRows, pending int
|
||||||
|
d.QueryRow(`SELECT COUNT(*) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&dndRows)
|
||||||
|
d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows)
|
||||||
|
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows)
|
||||||
|
d.QueryRow(`SELECT COALESCE(MAX(pending_setup),-1) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&pending)
|
||||||
|
|
||||||
|
if dndRows != 1 {
|
||||||
|
t.Fatalf("expected 1 auto-migrated dnd_character, got %d", dndRows)
|
||||||
|
}
|
||||||
|
if pending != 0 {
|
||||||
|
t.Errorf("auto-migrated character should be confirmed (pending_setup=0), got %d", pending)
|
||||||
|
}
|
||||||
|
if pmRows != 1 {
|
||||||
|
t.Errorf("player_meta not seeded for auto-migrant: got %d rows, want 1 (camcast straggler regression)", pmRows)
|
||||||
|
}
|
||||||
|
// createAdvCharacter seeds tier-0 gear in all five slots.
|
||||||
|
if equipRows != len(allSlots) {
|
||||||
|
t.Errorf("tier-0 equipment not seeded: got %d rows, want %d", equipRows, len(allSlots))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The character must now load through the legacy layer that camcast choked on.
|
||||||
|
if _, err := loadAdvCharacter(uid); err != nil {
|
||||||
|
t.Errorf("loadAdvCharacter after auto-migration: %v (this is the exact failure camcast hit)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsurePlayerMetaSeedIdempotent proves the guard never duplicates a legacy
|
||||||
|
// player's equipment — createAdvCharacter's tier-0 insert has no conflict guard,
|
||||||
|
// so the seed must be conditional on player_meta being absent.
|
||||||
|
func TestEnsurePlayerMetaSeedIdempotent(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@legacy:example.org")
|
||||||
|
|
||||||
|
// Seed once (fresh), then again (should be a no-op).
|
||||||
|
if err := ensurePlayerMetaSeed(uid); err != nil {
|
||||||
|
t.Fatalf("first seed: %v", err)
|
||||||
|
}
|
||||||
|
if err := ensurePlayerMetaSeed(uid); err != nil {
|
||||||
|
t.Fatalf("second seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var pmRows, equipRows int
|
||||||
|
d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows)
|
||||||
|
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows)
|
||||||
|
if pmRows != 1 {
|
||||||
|
t.Errorf("player_meta rows = %d, want 1 (no duplication)", pmRows)
|
||||||
|
}
|
||||||
|
if equipRows != len(allSlots) {
|
||||||
|
t.Errorf("equipment rows = %d, want %d (guard must not re-insert gear)", equipRows, len(allSlots))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -638,6 +638,14 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
|||||||
|
|
||||||
// SendReply sends a reply to a specific event.
|
// SendReply sends a reply to a specific event.
|
||||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||||
|
// The sink is the headless capture seam: a reply is an outbound room
|
||||||
|
// message, so — like SendMessage/SendDM — it diverts here when installed and
|
||||||
|
// the live client is never touched. Without this branch, reply-only handlers
|
||||||
|
// (duels, !town, !rivals, !achievements) hit a nil client under the sink.
|
||||||
|
if b != nil && b.Sink != nil {
|
||||||
|
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
||||||
|
return err
|
||||||
|
}
|
||||||
content := textContent(text)
|
content := textContent(text)
|
||||||
if eventID != "" {
|
if eventID != "" {
|
||||||
content.RelatesTo = &event.RelatesTo{
|
content.RelatesTo = &event.RelatesTo{
|
||||||
|
|||||||
1908
internal/plugin/testdata/party_characterization.golden
vendored
Normal file
1908
internal/plugin/testdata/party_characterization.golden
vendored
Normal file
File diff suppressed because it is too large
Load Diff
151
internal/plugin/zone_combat_autocast.go
Normal file
151
internal/plugin/zone_combat_autocast.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// §6 — a caster casts in an auto-resolved fight.
|
||||||
|
//
|
||||||
|
// An ordinary room fight is runZoneCombatRoster → SimulateCombat on an 8-round
|
||||||
|
// clock: one breath, no turn engine, no action picker. The only spell that has
|
||||||
|
// ever landed there is a PendingCast the player queued BY HAND with `!cast`
|
||||||
|
// before walking in. Under autopilot nobody queues one — so for every room of
|
||||||
|
// every expedition, a caster fought with a weapon and nothing else.
|
||||||
|
//
|
||||||
|
// A cleric is the extreme case: no Extra Attack, a mace, and its whole kit
|
||||||
|
// unusable. Measured on the room path at L10, a wounded cleric loses 8% of
|
||||||
|
// ORDINARY room fights (a fighter loses 0.0%), and a room loss ends the entire
|
||||||
|
// expedition — "the fight drags on, X outlasts you, you retreat". That is why
|
||||||
|
// cleric fled 167 of 500 runs in the corpus while fighter, ranger and paladin
|
||||||
|
// fled none, and it is the whole of the "cleric is a weak class" gap.
|
||||||
|
//
|
||||||
|
// The tell: dnd_class_balance.go — the harness the class corpus was tuned on —
|
||||||
|
// ALWAYS hands a caster their best damage spell (pickBestDamageSpell +
|
||||||
|
// applyHarnessSpellCast) before it simulates. So the numbers we balanced against
|
||||||
|
// modelled a caster who casts, and the live room modelled one who does not. This
|
||||||
|
// closes that gap: the character fights with the kit it actually owns.
|
||||||
|
//
|
||||||
|
// It is NOT a new mechanic. A folded-in spell is additive pre-damage, exactly as
|
||||||
|
// a hand-queued PendingCast has always been, which is what keeps this comparable
|
||||||
|
// to the corpus rather than a fresh invention.
|
||||||
|
|
||||||
|
// autoCastForAutoResolve picks the caster's best damage spell, spends the slot,
|
||||||
|
// and folds the damage into the Combatant pair. Reports whether it cast.
|
||||||
|
//
|
||||||
|
// It reads the seat's ACTUAL remaining slots, not the class slot table.
|
||||||
|
// pickBestDamageSpell (the harness one) reads slotsForClassLevel — the
|
||||||
|
// theoretical pool — which is right for a one-fight harness and would be an
|
||||||
|
// infinite spell here: the same "no row to persist onto, so it arrives fresh"
|
||||||
|
// bug that gave the companion an unlimited body and an unlimited slot pool.
|
||||||
|
// A leveled spell cast here costs a real slot and stays spent until a rest.
|
||||||
|
func (p *AdventurePlugin) autoCastForAutoResolve(
|
||||||
|
uid id.UserID,
|
||||||
|
c *DnDCharacter,
|
||||||
|
playerStats *CombatStats,
|
||||||
|
playerMods *CombatModifiers,
|
||||||
|
enemyStats *CombatStats,
|
||||||
|
) bool {
|
||||||
|
// A hand-queued spell wins: the player already chose, applyPendingCast owns
|
||||||
|
// it, and casting a second one would be two spells in one action.
|
||||||
|
if c == nil || c.PendingCast != "" || !isSpellcaster(c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
spell, slot, ok := pickAutoResolveSpell(uid, c)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Leveled spells cost a slot. Spend it BEFORE the damage lands, and bail if
|
||||||
|
// the debit fails — a spell that could not be paid for must not be cast.
|
||||||
|
if slot > 0 {
|
||||||
|
spent, err := consumeSpellSlot(uid, slot)
|
||||||
|
if err != nil || !spent {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyHarnessSpellCast(c, spell, slot, playerStats, playerMods, enemyStats)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomSlotCap is the highest slot level an ordinary room may spend. Cantrips are
|
||||||
|
// free and always available; anything above this is reserved for the elite and
|
||||||
|
// the boss.
|
||||||
|
//
|
||||||
|
// The first cut of §6 had no cap — "never upcast" was supposed to be enough. It
|
||||||
|
// was not, and the sweep said so: room deaths fell for every caster (mage 131 →
|
||||||
|
// 105) while elite and boss deaths exploded (mage 7 → 38 and 19 → 61, sorcerer
|
||||||
|
// 14 → 51 and 15 → 65). Casters were winning the trash rooms and arriving at the
|
||||||
|
// thing that matters with an empty pool. Native-level is not a reserve, because
|
||||||
|
// a mage's native-level spells ARE its boss kit; only a level cap is.
|
||||||
|
//
|
||||||
|
// 2 is where the two demands stop overlapping. Every caster owns a damage spell
|
||||||
|
// at or below it (a cleric's guiding_bolt and inflict_wounds are both 1st), so
|
||||||
|
// the rooms still get a real spell — but a fireball, a spirit_guardians, a
|
||||||
|
// flame_strike, and every slot the turn engine would upcast into stays in the
|
||||||
|
// caster's pocket until there is something worth spending it on.
|
||||||
|
const roomSlotCap = 2
|
||||||
|
|
||||||
|
// pickAutoResolveSpell is the best damage spell this caster can actually cast
|
||||||
|
// right now: known, prepared, on their list, at or under the room slot cap, and
|
||||||
|
// backed by a slot they still hold.
|
||||||
|
//
|
||||||
|
// It casts at the spell's NATIVE level and never upcasts. simPickSpell upcasts
|
||||||
|
// aggressively — correct there, because it runs in the turn engine at an elite
|
||||||
|
// or a boss, which is what the big slots are for. A trash room is not, and a
|
||||||
|
// picker that nukes a goblin with a 5th-level slot would leave the caster
|
||||||
|
// swinging a stick at the thing that actually matters.
|
||||||
|
func pickAutoResolveSpell(uid id.UserID, c *DnDCharacter) (SpellDefinition, int, bool) {
|
||||||
|
known, err := listKnownSpells(uid)
|
||||||
|
if err != nil || len(known) == 0 {
|
||||||
|
return SpellDefinition{}, 0, false
|
||||||
|
}
|
||||||
|
slots, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
return SpellDefinition{}, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var best SpellDefinition
|
||||||
|
bestSlot := 0
|
||||||
|
bestScore := -1.0
|
||||||
|
for _, k := range known {
|
||||||
|
if !k.Prepared {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp, ok := lookupSpell(k.SpellID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch sp.Effect {
|
||||||
|
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto:
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The auto-resolve engine has no reaction window, same as everywhere else.
|
||||||
|
if sp.CastTime == CastReaction {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
onList := false
|
||||||
|
for _, cl := range sp.Classes {
|
||||||
|
if cl == c.Class {
|
||||||
|
onList = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !onList {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A cantrip is free; a leveled spell needs a slot still in hand, and the
|
||||||
|
// room only gets to reach for the small ones.
|
||||||
|
if sp.Level > 0 {
|
||||||
|
if sp.Level > roomSlotCap {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if score := spellExpectedDamage(sp, sp.Level, c.Level); score > bestScore {
|
||||||
|
best, bestSlot, bestScore = sp, sp.Level, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, bestSlot, bestScore >= 0
|
||||||
|
}
|
||||||
254
internal/plugin/zone_combat_autocast_test.go
Normal file
254
internal/plugin/zone_combat_autocast_test.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// §6 — the caster casts in an auto-resolved room fight.
|
||||||
|
//
|
||||||
|
// Before this, the ONLY spell that could land in an ordinary room was one the
|
||||||
|
// player hand-queued with `!cast` before walking in. On autopilot nobody queues
|
||||||
|
// one, so a cleric fought every room of every expedition with a mace, lost room
|
||||||
|
// fights a fighter never loses, and a room loss ends the whole expedition.
|
||||||
|
|
||||||
|
func autocastCleric(t *testing.T, tag string) (id.UserID, *DnDCharacter) {
|
||||||
|
t.Helper()
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
s := &SimRunner{P: p}
|
||||||
|
uid := id.UserID("@autocast-" + tag + ":example.org")
|
||||||
|
c, err := s.BuildCharacter(uid, ClassCleric, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return uid, c
|
||||||
|
}
|
||||||
|
|
||||||
|
func usedSlots(t *testing.T, uid id.UserID) int {
|
||||||
|
t.Helper()
|
||||||
|
slots, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for _, pair := range slots {
|
||||||
|
total += pair[1]
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoCast_CasterCastsAndPaysForIt(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid, c := autocastCleric(t, "pays")
|
||||||
|
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
|
||||||
|
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
t.Fatal("a level-10 cleric with a full slot pool cast nothing")
|
||||||
|
}
|
||||||
|
// It has to be PAID for. pickBestDamageSpell reads the theoretical class slot
|
||||||
|
// table; if we had reused it here the spell would be infinite — the same
|
||||||
|
// free-lunch bug that gave the companion an unlimited body. The slot is spent
|
||||||
|
// whether or not the spell connects, exactly as it is at a real table.
|
||||||
|
if used := usedSlots(t, uid); used != 1 {
|
||||||
|
t.Fatalf("slots used = %d, want exactly 1 — the spell must cost a real slot", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slot has to buy damage. Not on any GIVEN cast — under the room slot cap a
|
||||||
|
// cleric reaches for inflict_wounds, which is an attack-roll spell and is
|
||||||
|
// allowed to miss — but a caster who spends its pool and never scratches
|
||||||
|
// anything would be strictly worse than swinging the mace.
|
||||||
|
func TestAutoCast_SpellActuallyDealsDamage(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid, c := autocastCleric(t, "damage")
|
||||||
|
|
||||||
|
landed := 0
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 1000, AC: 10}
|
||||||
|
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mods.SpellPreDamage > 0 || enemy.MaxHP < 1000 {
|
||||||
|
landed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if landed == 0 {
|
||||||
|
t.Fatal("40 casts against AC 10 and not one dealt a point of damage — " +
|
||||||
|
"the caster is spending slots on air")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is finite: cast far more times than the caster has slots, and the
|
||||||
|
// ledger must converge rather than run forever. This is the free-lunch guard —
|
||||||
|
// reusing the harness's pickBestDamageSpell (which reads the theoretical class
|
||||||
|
// slot table, not the row) would cast an unlimited leveled spell every room.
|
||||||
|
//
|
||||||
|
// It converges BELOW the full pool, and that is correct: a cleric owns no damage
|
||||||
|
// spell at slot 2 or 4 (guiding_bolt and inflict_wounds are L1, spirit_guardians
|
||||||
|
// L3, flame_strike L5), and a trash room never upcasts. Those slots are not
|
||||||
|
// wasted — the turn engine upcasts them at the elite and the boss, which is what
|
||||||
|
// they are for.
|
||||||
|
func TestAutoCast_SlotPoolIsFiniteAndConverges(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid, c := autocastCleric(t, "finite")
|
||||||
|
|
||||||
|
before, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool := 0
|
||||||
|
for _, pair := range before {
|
||||||
|
pool += pair[0]
|
||||||
|
}
|
||||||
|
if pool == 0 {
|
||||||
|
t.Fatal("test cleric has no slots at all")
|
||||||
|
}
|
||||||
|
|
||||||
|
cast := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 1000, AC: 14}
|
||||||
|
p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cast(pool + 10)
|
||||||
|
drained := usedSlots(t, uid)
|
||||||
|
if drained == 0 {
|
||||||
|
t.Fatal("the cleric never spent a slot — it is not really casting")
|
||||||
|
}
|
||||||
|
if drained > pool {
|
||||||
|
t.Fatalf("slots used = %d > pool %d — slots are being conjured", drained, pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dry means dry: another twenty rooms must not find another leveled slot.
|
||||||
|
cast(20)
|
||||||
|
if after := usedSlots(t, uid); after != drained {
|
||||||
|
t.Fatalf("slots used went %d → %d after the pool was dry — a leveled spell is being cast for free",
|
||||||
|
drained, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a dry caster still throws a cantrip rather than reverting to a stick.
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 1000, AC: 14}
|
||||||
|
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
t.Fatal("an out-of-slots caster cast nothing at all; a cantrip is free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hand-queued spell wins. applyPendingCast already owns that cast; auto-casting
|
||||||
|
// on top of it would fire two spells in one action.
|
||||||
|
func TestAutoCast_HandQueuedSpellWins(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid, c := autocastCleric(t, "queued")
|
||||||
|
|
||||||
|
c.PendingCast = encodePendingCast(PendingCast{SpellID: "guiding_bolt", SlotLevel: 1})
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
t.Fatal("auto-cast fired on top of a hand-queued spell — that is two spells in one action")
|
||||||
|
}
|
||||||
|
if used := usedSlots(t, uid); used != 0 {
|
||||||
|
t.Fatalf("slots used = %d; the auto-cast must not spend anything when the player already queued", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A martial has no spellbook and must be left exactly as it was — the balance
|
||||||
|
// corpus rests on the martials not moving.
|
||||||
|
func TestAutoCast_MartialIsUntouched(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
s := &SimRunner{P: p}
|
||||||
|
uid := id.UserID("@autocast-fighter:example.org")
|
||||||
|
c, err := s.BuildCharacter(uid, ClassFighter, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
t.Fatal("a fighter cast a spell")
|
||||||
|
}
|
||||||
|
if mods.SpellPreDamage != 0 {
|
||||||
|
t.Fatalf("fighter picked up %d spell pre-damage", mods.SpellPreDamage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never upcast in a trash room: the big slots are what the elite and the boss
|
||||||
|
// are for, and the turn engine spends them there.
|
||||||
|
func TestAutoCast_DoesNotBurnTheBigSlots(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid, c := autocastCleric(t, "native")
|
||||||
|
|
||||||
|
spell, slot, ok := pickAutoResolveSpell(uid, c)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("cleric picked nothing")
|
||||||
|
}
|
||||||
|
if slot != spell.Level {
|
||||||
|
t.Fatalf("%s cast at slot %d, native level %d — an ordinary room must not upcast",
|
||||||
|
spell.ID, slot, spell.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The boss kit survives the walk in. This is the regression the first cut of §6
|
||||||
|
// shipped and the sweep caught: with no cap, a caster spent its whole pool on
|
||||||
|
// goblins and reached the boss empty — room deaths fell but boss deaths tripled
|
||||||
|
// (mage 19 → 61, sorcerer 15 → 65). "Never upcast" is not a reserve, because a
|
||||||
|
// mage's native-level spells ARE its boss kit.
|
||||||
|
//
|
||||||
|
// So: grind a full expedition's worth of rooms, and every slot above the cap
|
||||||
|
// must still be sitting there untouched when the dragon opens its eyes.
|
||||||
|
func TestAutoCast_ReservesTheBossSlots(t *testing.T) {
|
||||||
|
for _, class := range []DnDClass{ClassMage, ClassSorcerer, ClassCleric, ClassWarlock} {
|
||||||
|
t.Run(string(class), func(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
s := &SimRunner{P: p}
|
||||||
|
uid := id.UserID("@autocast-reserve-" + string(class) + ":example.org")
|
||||||
|
c, err := s.BuildCharacter(uid, class, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long expedition: far more rooms than the caster has slots.
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 1000, AC: 14}
|
||||||
|
p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy)
|
||||||
|
}
|
||||||
|
|
||||||
|
slots, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for lvl, pair := range slots {
|
||||||
|
if lvl <= roomSlotCap {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pair[1] != 0 {
|
||||||
|
t.Errorf("level-%d slots: %d of %d spent in ordinary rooms — "+
|
||||||
|
"that is the boss's kit, and the caster will arrive empty",
|
||||||
|
lvl, pair[1], pair[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
advChar *AdventureCharacter
|
advChar *AdventureCharacter
|
||||||
equip map[EquipmentSlot]*AdvEquipment
|
equip map[EquipmentSlot]*AdvEquipment
|
||||||
mods CombatModifiers
|
mods CombatModifiers
|
||||||
|
// companion marks the hired NPC seat: it fights, but it owns none of the
|
||||||
|
// character-scoped effects the close-out loop applies, and its dndChar /
|
||||||
|
// advChar / equip are nil precisely so a missed guard panics loudly here
|
||||||
|
// rather than silently writing rows for a bot.
|
||||||
|
companion bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -58,6 +63,10 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
builds []seatBuild
|
builds []seatBuild
|
||||||
seated []id.UserID
|
seated []id.UserID
|
||||||
enemy Combatant
|
enemy Combatant
|
||||||
|
// Parallel to players: what each seat is worth, priced once the roster is
|
||||||
|
// final. A member who sat the encounter out is in none of these.
|
||||||
|
levels []int
|
||||||
|
companions []bool
|
||||||
)
|
)
|
||||||
|
|
||||||
for i, uid := range roster {
|
for i, uid := range roster {
|
||||||
@@ -69,6 +78,29 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
return PartyCombatResult{}, nil, err
|
return PartyCombatResult{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The hired companion fights here too — the auto-resolve path is where
|
||||||
|
// most expedition rooms are actually decided. He is synthesized rather
|
||||||
|
// than loaded, and his seatBuild is flagged so the close-out loop below
|
||||||
|
// gives him no XP, no loot, and no post-combat persistence.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
expID := companionExpeditionFor(roster[0])
|
||||||
|
class, level := companionLoadout(expID)
|
||||||
|
player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
||||||
|
// The wounds he carries into this fight. His synthetic sheet is always
|
||||||
|
// written at full HP, so applyDnDHPScaling left StartHP at the
|
||||||
|
// "enter at MaxHP" sentinel and he healed himself for free between every
|
||||||
|
// room of the run. See companionSeatHP.
|
||||||
|
player.Stats.StartHP = companionSeatHP(expID, player.Stats.MaxHP)
|
||||||
|
if leader {
|
||||||
|
enemy = e
|
||||||
|
}
|
||||||
|
players = append(players, player)
|
||||||
|
builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true})
|
||||||
|
seated = append(seated, uid)
|
||||||
|
levels, companions = append(levels, level), append(companions, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
advChar, err := loadAdvCharacter(uid)
|
advChar, err := loadAdvCharacter(uid)
|
||||||
if err != nil || advChar == nil {
|
if err != nil || advChar == nil {
|
||||||
if leader {
|
if leader {
|
||||||
@@ -120,6 +152,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
// Combatant once, before the fight runs. The queued spell can also debuff
|
// Combatant once, before the fight runs. The queued spell can also debuff
|
||||||
// the shared enemy, so every seat's cast lands on the one stat block.
|
// the shared enemy, so every seat's cast lands on the one stat block.
|
||||||
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
||||||
|
// §6 — and if they queued nothing, they still cast. This engine has no
|
||||||
|
// action picker, so before this a caster on autopilot swung a weapon for
|
||||||
|
// the whole fight and their spellbook may as well not have existed. The
|
||||||
|
// slot is really spent; see autoCastForAutoResolve.
|
||||||
|
p.autoCastForAutoResolve(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
||||||
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
|
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
|
||||||
|
|
||||||
players = append(players, player)
|
players = append(players, player)
|
||||||
@@ -127,8 +164,21 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
||||||
})
|
})
|
||||||
seated = append(seated, uid)
|
seated = append(seated, uid)
|
||||||
|
lvl := 0
|
||||||
|
if dndChar != nil {
|
||||||
|
lvl = dndChar.Level
|
||||||
|
}
|
||||||
|
levels, companions = append(levels, lvl), append(companions, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What each seat costs the enemy, priced against the leader — over the seats
|
||||||
|
// that were actually seated, so a member left behind is not charged.
|
||||||
|
ptrs := make([]*Combatant, len(players))
|
||||||
|
for i := range players {
|
||||||
|
ptrs[i] = &players[i]
|
||||||
|
}
|
||||||
|
applySeatWeights(ptrs, levels, companions)
|
||||||
|
|
||||||
res := simulateParty(players, enemy, phases)
|
res := simulateParty(players, enemy, phases)
|
||||||
dumpCombatEventsIfDebug(
|
dumpCombatEventsIfDebug(
|
||||||
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
|
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
|
||||||
@@ -137,6 +187,19 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
for i, b := range builds {
|
for i, b := range builds {
|
||||||
seatRes := res.Seats[i]
|
seatRes := res.Seats[i]
|
||||||
|
|
||||||
|
// The companion swings and then goes back to filing copy: no inventory to
|
||||||
|
// deduct from, no sheet to persist to, no XP to earn. Every call below
|
||||||
|
// would either write rows for a bot or log an error about the rows it
|
||||||
|
// hasn't got.
|
||||||
|
//
|
||||||
|
// His HP is the exception, and it is not bookkeeping — it is the fight's
|
||||||
|
// result. Without it he re-enters the next room at full while the humans
|
||||||
|
// beside him carry every wound to camp.
|
||||||
|
if b.companion {
|
||||||
|
_ = setCompanionHP(companionExpeditionFor(roster[0]), seatRes.PlayerEndHP)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the actual heal items consumed during combat (one inventory item
|
// Remove the actual heal items consumed during combat (one inventory item
|
||||||
// per heal_item event this seat fired). Cheapest-tier first.
|
// per heal_item event this seat fired). Cheapest-tier first.
|
||||||
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
|
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
|
||||||
@@ -193,6 +256,12 @@ func (p *AdventurePlugin) closeOutZoneWin(
|
|||||||
) (leaderDrop string, downed []id.UserID) {
|
) (leaderDrop string, downed []id.UserID) {
|
||||||
party := len(seated) > 1
|
party := len(seated) > 1
|
||||||
for i, uid := range seated {
|
for i, uid := range seated {
|
||||||
|
// The companion takes no cut and cannot die. He is not in the downed list
|
||||||
|
// either: that list is what the room narration mourns, and the party did
|
||||||
|
// not lose a friend when the hireling took a nap.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if res.Seats[i].PlayerEndHP > 0 {
|
if res.Seats[i].PlayerEndHP > 0 {
|
||||||
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
|
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
@@ -217,6 +286,12 @@ func (p *AdventurePlugin) closeOutZoneWin(
|
|||||||
// fight.
|
// fight.
|
||||||
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
|
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
|
||||||
for i, uid := range seated {
|
for i, uid := range seated {
|
||||||
|
// The companion is not killed and is not counted among the dead — he is
|
||||||
|
// not in the graveyard, and a wipe that lists him would have the news bot
|
||||||
|
// reporting its own funeral.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if res.Seats[i].PlayerEndHP <= 0 {
|
if res.Seats[i].PlayerEndHP <= 0 {
|
||||||
markAdventureDead(uid, deathSource, zone.Display)
|
markAdventureDead(uid, deathSource, zone.Display)
|
||||||
killed = append(killed, uid)
|
killed = append(killed, uid)
|
||||||
|
|||||||
9
main.go
9
main.go
@@ -13,6 +13,7 @@ import (
|
|||||||
"gogobee/internal/bot"
|
"gogobee/internal/bot"
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
"gogobee/internal/dreamclient"
|
"gogobee/internal/dreamclient"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
"gogobee/internal/plugin"
|
"gogobee/internal/plugin"
|
||||||
"gogobee/internal/util"
|
"gogobee/internal/util"
|
||||||
"gogobee/internal/version"
|
"gogobee/internal/version"
|
||||||
@@ -55,6 +56,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
db.RecordStartup(version.Version, version.Commit)
|
db.RecordStartup(version.Version, version.Commit)
|
||||||
|
|
||||||
|
// Pete adventure-news seam: wire the outbound fact client from the
|
||||||
|
// environment (no-op unless FEATURE_PETE_NEWS=true). The background sender
|
||||||
|
// is started once the run context exists (below).
|
||||||
|
peteclient.Init()
|
||||||
|
|
||||||
// Create Matrix session. AUTH_MODE selects the transport:
|
// Create Matrix session. AUTH_MODE selects the transport:
|
||||||
// masdevice (default) — MAS OAuth device grant over /sync.
|
// masdevice (default) — MAS OAuth device grant over /sync.
|
||||||
// appservice — as_token auth over Synapse transaction push.
|
// appservice — as_token auth over Synapse transaction push.
|
||||||
@@ -97,6 +103,9 @@ func main() {
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Drain the Pete adventure-news emit queue in the background until shutdown.
|
||||||
|
peteclient.StartSender(ctx)
|
||||||
|
|
||||||
// Show the bot online from boot (appservice mode; no-op under masdevice, where
|
// Show the bot online from boot (appservice mode; no-op under masdevice, where
|
||||||
// /sync refreshes presence implicitly). Safe before the listener — outbound only.
|
// /sync refreshes presence implicitly). Safe before the listener — outbound only.
|
||||||
sess.StartPresence(ctx)
|
sess.StartPresence(ctx)
|
||||||
|
|||||||
Reference in New Issue
Block a user