mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
16 Commits
sim-real-c
...
da398cf674
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da398cf674 | ||
|
|
e98029e6ac | ||
|
|
064ecb1848 | ||
|
|
bae83271fe | ||
|
|
c9128fb0d6 | ||
|
|
055f07d3c0 | ||
|
|
27b9de5936 | ||
|
|
01c2cb2f0b | ||
|
|
d538f91cf7 | ||
|
|
f4a4c0d30b | ||
|
|
1634bb1970 | ||
|
|
ae7ff38996 | ||
|
|
b42beec348 | ||
|
|
0c4c4757d3 | ||
|
|
fedd357a29 | ||
|
|
59319ede9d |
@@ -48,7 +48,7 @@ func main() {
|
||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||
|
||||
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
||||
@@ -62,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")
|
||||
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()")
|
||||
)
|
||||
@@ -88,7 +89,7 @@ func main() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -103,7 +104,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers, *companion)
|
||||
}
|
||||
|
||||
// runReal drives an existing character loaded from dataDir's gogobee.db through
|
||||
@@ -161,7 +162,7 @@ func followerClasses(leaderClass string, party int, spec string) []string {
|
||||
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
|
||||
if dir == "" {
|
||||
var err error
|
||||
@@ -172,7 +173,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
|
||||
defer os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers)
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers, companion)
|
||||
if err != nil {
|
||||
if res != nil {
|
||||
if !includeLog {
|
||||
@@ -199,7 +200,7 @@ type matrixJob struct {
|
||||
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)
|
||||
ls := parseLevels(levels)
|
||||
zs := splitNonEmpty(zones)
|
||||
@@ -230,7 +231,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < jobs; i++ {
|
||||
wg.Add(1)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
|
||||
}
|
||||
go func() {
|
||||
for _, j := range work {
|
||||
@@ -249,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()
|
||||
for j := range in {
|
||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
||||
@@ -275,6 +276,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
||||
if partyClasses != "" {
|
||||
args = append(args, "-party-classes", partyClasses)
|
||||
}
|
||||
if companion != "" {
|
||||
args = append(args, "-companion", companion)
|
||||
}
|
||||
if trace {
|
||||
args = append(args, "-trace")
|
||||
}
|
||||
@@ -314,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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init runner: %w", err)
|
||||
@@ -338,6 +342,7 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
|
||||
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
|
||||
members = append(members, muid)
|
||||
}
|
||||
runner.Companion = companion
|
||||
return runner.RunPartyExpedition(uid, members, zone, cap, days)
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +401,41 @@ func runMigrations(d *sql.DB) error {
|
||||
// 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 {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -616,6 +651,15 @@ func RunMaintenance() {
|
||||
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Pete adventure-news queue — reap delivered rows (kept only for
|
||||
// idempotency); undelivered rows stay so the sender can retry/park them.
|
||||
{"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}},
|
||||
// ...and reap permanently-parked rows: the sender exhausts its retries
|
||||
// within a few hours, so anything still unsent after 30 days is dead
|
||||
// weight the drain query already skips — drop it so a durable outage
|
||||
// can't accrete rows forever.
|
||||
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Rate limits — purge entries older than today
|
||||
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
||||
|
||||
@@ -947,6 +991,46 @@ CREATE TABLE IF NOT EXISTS shade_optout (
|
||||
user_id TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
-- Pete adventure-news seam: durable outbound queue of game-event facts sent to
|
||||
-- the Pete news bot. Emit enqueues (INSERT OR IGNORE on guid = idempotency); a
|
||||
-- background sender drains rows where sent_at IS NULL. Keyed on the fact guid so
|
||||
-- retries and duplicate emits collapse to one row.
|
||||
CREATE TABLE IF NOT EXISTS pete_emit_queue (
|
||||
guid TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||
sent_at INTEGER
|
||||
);
|
||||
|
||||
-- Players who opted out of being named in Pete's adventure news. Enforced at
|
||||
-- emit time (anonymize, never delete). Mirrors shade_optout.
|
||||
CREATE TABLE IF NOT EXISTS news_optout (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
opted_out_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Realm-first ledger for adventure news: the first (kind,target) occurrence
|
||||
-- across all players. INSERT OR IGNORE returns rows-affected>0 exactly once, so
|
||||
-- the first clear of a zone/boss fires as a PRIORITY realm-first and everyone
|
||||
-- after as a BULLETIN personal clear. Seeded by the cold-start backfill so a
|
||||
-- fresh deploy doesn't re-flag historically-cleared content as first-ever.
|
||||
CREATE TABLE IF NOT EXISTS news_realm_firsts (
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
first_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (kind, target)
|
||||
);
|
||||
|
||||
-- Durable runtime config for adventure news (e.g. the emission kill-switch).
|
||||
-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after
|
||||
-- 7 days, which would silently revert an operator's !news off back to on.
|
||||
CREATE TABLE IF NOT EXISTS news_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Birthdays
|
||||
CREATE TABLE IF NOT EXISTS birthdays (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
|
||||
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"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"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) {
|
||||
_, err := d.Exec(
|
||||
res, err := d.Exec(
|
||||
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
||||
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)
|
||||
return
|
||||
}
|
||||
// Only a genuinely new grant is news; a re-check conflict (rows==0) is not.
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return
|
||||
}
|
||||
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
|
||||
p.emitMilestoneNews(d, userID, achievementID)
|
||||
}
|
||||
|
||||
// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements
|
||||
// only — rarity-gated to a single holder (per the news selection thresholds) so
|
||||
// routine unlocks don't flood the feed. Character name only; no-op unless the
|
||||
// seam is enabled.
|
||||
func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) {
|
||||
var holders int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 {
|
||||
return
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
label := achievementID
|
||||
for _, a := range p.achievements {
|
||||
if a.ID == achievementID {
|
||||
label = a.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(userID, achievementID), achievementID),
|
||||
EventType: "milestone",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Milestone: label,
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||
|
||||
@@ -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: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
|
||||
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
|
||||
{Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +248,10 @@ func (p *AdventurePlugin) Init() error {
|
||||
// arrival roll. Both idempotent via JobCompleted gates.
|
||||
bootstrapCasterSpellBackfill()
|
||||
bootstrapGrantStarterPet()
|
||||
// Pete adventure-news cold-start: replay the back-catalogue (realm-firsts,
|
||||
// deaths, single-holder achievements) the first boot the seam is live, so
|
||||
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||
@@ -438,6 +443,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "rivals") {
|
||||
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "news") {
|
||||
return p.handleNewsCmd(ctx)
|
||||
}
|
||||
|
||||
// 1. Arena commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "bail") {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -536,6 +539,41 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ensurePlayerMetaSeed guarantees the canonical player_meta seed row (and tier-0
|
||||
// equipment) exists for userID, creating it only when absent. The auto-migration
|
||||
// path (ensureDnDCharacterForCombat) writes a confirmed dnd_character without
|
||||
// touching the legacy layer; without this, a brand-new player whose first-ever
|
||||
// action auto-migrates — e.g. !rest or !cast before !setup — ends up with a
|
||||
// player_meta-less character that fails every legacy-layer command with
|
||||
// "sql: no rows" (the camcast straggler). Conditional on absence and thus
|
||||
// idempotent: legacy players who already have player_meta keep their equipment
|
||||
// untouched — createAdvCharacter's tier-0 equipment insert has no conflict guard
|
||||
// and would otherwise duplicate their gear.
|
||||
func ensurePlayerMetaSeed(userID id.UserID) error {
|
||||
d := db.Get()
|
||||
var one int
|
||||
err := d.QueryRow(`SELECT 1 FROM player_meta WHERE user_id = ?`, string(userID)).Scan(&one)
|
||||
if err == nil {
|
||||
return nil // already seeded
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
return createAdvCharacter(userID, localpartOf(userID))
|
||||
}
|
||||
|
||||
// localpartOf returns the mxid localpart (between @ and :) as a display-name
|
||||
// fallback — matches Base.DisplayName's offline behavior. The seed's display
|
||||
// name is overlaid by later player_meta upserts; this is just a sane default
|
||||
// for a character born without a Matrix client in reach.
|
||||
func localpartOf(userID id.UserID) string {
|
||||
s := string(userID)
|
||||
if i := strings.Index(s, ":"); i > 0 {
|
||||
return s[1:i]
|
||||
}
|
||||
return strings.TrimPrefix(s, "@")
|
||||
}
|
||||
|
||||
// saveAdvCharacter persists every mutable AdventureCharacter field to
|
||||
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
|
||||
// retired — the row is now read-only after createAdvCharacter seeds it,
|
||||
|
||||
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"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -613,6 +614,22 @@ func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, c
|
||||
upsertRivalRecord(winnerID, loserID, true)
|
||||
upsertRivalRecord(loserID, winnerID, false)
|
||||
|
||||
// One BULLETIN dispatch per settled duel (from the winner's side, so it
|
||||
// fires once). Character names only; no-op unless the seam is enabled.
|
||||
if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" {
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("rival:%d", ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), ts),
|
||||
EventType: "rival_result",
|
||||
Tier: "bulletin",
|
||||
Subject: wn,
|
||||
Opponent: ln,
|
||||
Outcome: "won",
|
||||
OccurredAt: ts,
|
||||
}, winnerID, loserID)
|
||||
}
|
||||
|
||||
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||||
// 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
|
||||
// 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
|
||||
// player-facing on any validation failure. It performs NO resource spend —
|
||||
// the caller debits the slot only once the round is about to resolve.
|
||||
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
||||
//
|
||||
// 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)
|
||||
upcast := 0
|
||||
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)
|
||||
}
|
||||
}
|
||||
known, prepared, err := playerKnowsSpell(userID, spell.ID)
|
||||
known, prepared, err := seatKnowsSpell(sess, seat, userID, spell.ID)
|
||||
if err != nil {
|
||||
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
|
||||
// delta went to the session's embedded copy — seat 0 — so a party member
|
||||
// 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) {
|
||||
noop := func(bool) {}
|
||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||
|
||||
advChar, _ := loadAdvCharacter(uid)
|
||||
c, err := p.ensureCharForDnDCmd(uid, advChar)
|
||||
// §1 — a heal may name somebody else in the fight: `!cast cure wounds @alex`.
|
||||
// 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 {
|
||||
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))
|
||||
}
|
||||
|
||||
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
|
||||
spell, slotLevel, errMsg := parseCombatCast(ct.sess, seat, uid, c, strings.TrimSpace(args))
|
||||
if errMsg != "" {
|
||||
return PlayerAction{}, noop, errMsg
|
||||
}
|
||||
@@ -551,7 +635,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
|
||||
refund := func(ok bool) {
|
||||
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(
|
||||
"%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
|
||||
}
|
||||
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(
|
||||
"%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
|
||||
}
|
||||
// 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,
|
||||
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
|
||||
// (EnemyDamage) and the same value re-ticks every round_end after, via
|
||||
// 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
|
||||
// errors. Material components (rare in a fight) are not refunded if the slot
|
||||
// debit then fails — matching the auto-resolve cast path.
|
||||
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||||
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 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") {
|
||||
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
||||
}
|
||||
}
|
||||
if spell.Level > 0 {
|
||||
ok, serr := consumeSpellSlot(userID, slotLevel)
|
||||
ok, serr := consumeSeatSlot(sess, seat, userID, slotLevel)
|
||||
if serr != nil {
|
||||
return "Couldn't consume slot: " + serr.Error()
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,22 @@ type Combatant struct {
|
||||
Mods CombatModifiers
|
||||
IsPlayer bool
|
||||
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 {
|
||||
@@ -220,7 +236,14 @@ type CombatEvent struct {
|
||||
//
|
||||
// It exists so a party's play-by-play can name the right person. Solo events
|
||||
// are all seat 0, and the omitempty tag keeps the field out of every solo
|
||||
// turn_log_json — rows written before N3/P5 decode unchanged.
|
||||
// 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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatP
|
||||
// scaled action economy to actually threaten each member. Solo is unchanged
|
||||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||||
if len(players) > 1 {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players))
|
||||
}
|
||||
enemyStart := 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
|
||||
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
||||
// 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 {
|
||||
n := len(st.actors)
|
||||
if n < 2 {
|
||||
if livingActors(st) < 2 {
|
||||
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)
|
||||
base := int(exp)
|
||||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||||
@@ -320,6 +335,29 @@ func enemyActionsThisRound(st *combatState) int {
|
||||
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
|
||||
// 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
|
||||
@@ -333,17 +371,108 @@ func enemyActionsThisRound(st *combatState) int {
|
||||
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
||||
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
||||
// composition below its solo clear rate — bringing a friend is never a penalty.
|
||||
func partyActionExpectation(n int) float64 {
|
||||
// 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 {
|
||||
case n < 2:
|
||||
case n <= 1:
|
||||
return 1
|
||||
case n == 2:
|
||||
return 2.4
|
||||
case n <= 2:
|
||||
// (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:
|
||||
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
|
||||
// (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
|
||||
@@ -355,18 +484,27 @@ func partyActionExpectation(n int) float64 {
|
||||
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
||||
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||||
// not the 100% faceroll a single enemy swing produced.
|
||||
func partyEnemyHPScale(rosterSize int) float64 {
|
||||
if rosterSize < 2 {
|
||||
// Like partyActionExpectation it takes the summed SeatWeight rather than a head
|
||||
// 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.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
|
||||
// rule, so every call site (the auto-resolve engine, the party session's initial
|
||||
// persist, and the per-turn rebuild) agrees on the same number.
|
||||
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
|
||||
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
|
||||
func scaledEnemyMaxHP(baseMaxHP int, weight float64) int {
|
||||
return int(float64(baseMaxHP) * partyEnemyHPScale(weight))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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++ {
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
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
|
||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||
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))
|
||||
p.postCombatBookkeepingForSeat(sess, seat)
|
||||
}
|
||||
@@ -100,6 +112,16 @@ func (p *AdventurePlugin) finishPartyWin(
|
||||
uid := id.UserID(sess.seatUserID(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.
|
||||
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)
|
||||
out := make([]string, 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
|
||||
if line != "" && seat == 0 {
|
||||
b.WriteString(line + "\n")
|
||||
@@ -220,9 +252,9 @@ func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
||||
}
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
reason := "combat flee"
|
||||
reason := lossCombatFlee
|
||||
if death {
|
||||
reason = "combat death"
|
||||
reason = lossCombatDeath
|
||||
}
|
||||
forceExtractExpeditionForRunLoss(owner, reason)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ func fightRoster(sender id.UserID) []id.UserID {
|
||||
if err != nil || e == nil {
|
||||
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
|
||||
@@ -56,9 +58,40 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
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 {
|
||||
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
|
||||
// before it: consuming a seat's armed ability and *then* sitting them out
|
||||
// would spend their rage on a fight they never joined.
|
||||
@@ -94,7 +127,7 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
trySimAutoArm(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 leader {
|
||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||
@@ -113,7 +146,17 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
seats = append(seats, CombatSeatSetup{
|
||||
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, ""
|
||||
}
|
||||
|
||||
@@ -159,6 +202,12 @@ func (p *AdventurePlugin) announcePartyFightStart(
|
||||
names[i] = c.Name
|
||||
}
|
||||
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
|
||||
b.WriteString(header)
|
||||
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))
|
||||
}
|
||||
|
||||
// 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.
|
||||
sess.actorStatusesPtr(seat).Autopilot = false
|
||||
|
||||
@@ -315,6 +324,33 @@ func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
|
||||
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
|
||||
// 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
|
||||
@@ -387,6 +423,12 @@ func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEven
|
||||
names := ct.seatNames()
|
||||
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
||||
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,
|
||||
// so each member's own events must be theirs and nobody else's.
|
||||
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).
|
||||
// Without the latch an away member taxes the party the full deadline every
|
||||
// single round; with it, they cost one wait and then resolve instantly. Any
|
||||
// combat command from that member clears it.
|
||||
// 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
|
||||
// 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.
|
||||
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.
|
||||
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||
@@ -256,7 +274,8 @@ func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||
st.HealChargesLeft = playerMods.HealItemCharges
|
||||
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.ArmedAbility != ""
|
||||
}
|
||||
@@ -387,16 +406,30 @@ func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
|
||||
// turn silently rather than blocking the round on a corpse.
|
||||
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
||||
|
||||
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
|
||||
// by a lapsed turn deadline.
|
||||
// seatIsEngineDriven reports whether a seat has no human behind it at all — a
|
||||
// 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 {
|
||||
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
|
||||
// waiting on its player: it is down (forfeits silently) or latched onto
|
||||
// autopilot. driveCombatRound keeps stepping while this holds, so a round only
|
||||
// comes to rest on a live human's turn.
|
||||
// waiting on its player: it is down (forfeits silently), latched onto autopilot,
|
||||
// or has no human to wait for. driveCombatRound keeps stepping while this holds,
|
||||
// so a round only comes to rest on a live human's turn.
|
||||
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
||||
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
||||
}
|
||||
@@ -491,7 +524,7 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
||||
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
||||
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
||||
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
|
||||
owner := seats[0]
|
||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||
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
|
||||
// later rebuild can re-apply the ability without re-spending it.
|
||||
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).
|
||||
|
||||
@@ -154,13 +154,36 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
|
||||
seats := sess.SeatUserIDs()
|
||||
players := make([]*Combatant, len(seats))
|
||||
levels := make([]int, len(seats))
|
||||
companions := make([]bool, len(seats))
|
||||
var enemy Combatant
|
||||
for seat, uid := range seats {
|
||||
// The ability this seat armed was consumed once, at fight start, and its
|
||||
// id parked on their statuses. Re-applying it here — not re-consuming —
|
||||
// is what makes a rage last the whole fight instead of one round.
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||
|
||||
// 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 {
|
||||
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.
|
||||
applySessionBuffs(&player, st)
|
||||
players[seat] = &player
|
||||
if dc != nil {
|
||||
levels[seat] = dc.Level
|
||||
}
|
||||
if seat == 0 {
|
||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||
// Only the *player* half of the build varies by seat.
|
||||
enemy = e
|
||||
// Party-only enemy HP bump, re-derived each turn from the template so
|
||||
// it never compounds. Matches the scalar startPartyCombatSession used
|
||||
// for the initial persist; solo (roster 1) scales by 1.0.
|
||||
if sess.IsParty() {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// needs: the Berserker's rage flag, which decides whether the character owes a
|
||||
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
||||
|
||||
@@ -65,6 +65,19 @@ type turnActionEffect struct {
|
||||
EnemyDamage int
|
||||
PlayerHeal int
|
||||
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
|
||||
// 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
|
||||
@@ -560,6 +573,25 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||
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
|
||||
// the old one (5e: one concentration at a time); non-concentration casts
|
||||
// leave any running aura alone.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -27,9 +29,11 @@ import (
|
||||
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
||||
// a "Phase 11" note — they require the boss turn-based engine.
|
||||
//
|
||||
// Cross-player targeting (--target @user) is deliberately deferred. SP2
|
||||
// supports self-target only. Heals on self work; ally buffs queue with the
|
||||
// caster as the target until SP3 wires up the multi-player resolution.
|
||||
// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human
|
||||
// on your expedition — see dnd_cast_target.go. Only heals may name somebody else
|
||||
// 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).
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
tokens := strings.Fields(args)
|
||||
upcast := 0
|
||||
@@ -111,11 +122,6 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
}
|
||||
i++
|
||||
}
|
||||
case "--target":
|
||||
// Reserved for SP3 — accept and ignore for now.
|
||||
if i+1 < len(tokens) {
|
||||
i++
|
||||
}
|
||||
default:
|
||||
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).
|
||||
if spell.MaterialCost > 0 {
|
||||
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.
|
||||
switch spell.Effect {
|
||||
case EffectSpellHeal:
|
||||
if targetUser != "" {
|
||||
return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel)
|
||||
}
|
||||
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
||||
case EffectUtility:
|
||||
return p.resolveUtility(ctx, c, spell, slotLevel)
|
||||
@@ -231,7 +257,10 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
|
||||
// ── 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)
|
||||
if dice == 0 {
|
||||
dice, faces = 1, 8 // safety fallback
|
||||
@@ -253,6 +282,11 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
||||
}
|
||||
heal += abilityModifier(c.WIS)
|
||||
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
|
||||
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
||||
@@ -272,6 +306,51 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
b.WriteString("**Cast a spell**\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("Run `!spells` for your full list.\n\n")
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -305,6 +307,13 @@ func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*D
|
||||
c := autoBuildCharacter(userID, char)
|
||||
c.AutoMigrated = true
|
||||
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 {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -567,4 +576,80 @@ func markAdventureDead(userID id.UserID, source, location string) {
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
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, "")
|
||||
}
|
||||
|
||||
@@ -325,6 +325,13 @@ func applyCampRest(e *Expedition, kind string) string {
|
||||
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
|
||||
_ = refreshAllResources(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)
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,10 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
return p.expeditionCmdParty(ctx)
|
||||
case "leave":
|
||||
return p.expeditionCmdLeave(ctx)
|
||||
case "hire":
|
||||
return p.expeditionCmdHire(ctx, rest)
|
||||
case "dismiss":
|
||||
return p.expeditionCmdDismiss(ctx)
|
||||
case "extract":
|
||||
return p.handleExtractCmd(ctx, "")
|
||||
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 accept` / `!expedition decline` — answer an invite\n")
|
||||
b.WriteString("`!expedition party` — who's with you\n")
|
||||
b.WriteString("`!expedition leave` — turn back for town (members only)\n\n")
|
||||
b.WriteString("`!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("`!extract` — bail safely (resumable for 7 days)\n")
|
||||
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
|
||||
|
||||
@@ -95,21 +95,40 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
|
||||
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
|
||||
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
||||
// death) into the forced-extract flow. Those sites already abandon the
|
||||
// zone run, but without flipping the wrapping expedition the ambient
|
||||
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
||||
// 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) {
|
||||
exp, err := getActiveExpedition(userID)
|
||||
if err != nil || exp == nil {
|
||||
return
|
||||
}
|
||||
day, zoneID := exp.CurrentDay, exp.ZoneID
|
||||
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
||||
slog.Warn("expedition: force-extract on run loss",
|
||||
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
||||
return
|
||||
}
|
||||
emitRetreatNews(userID, reason, zoneID, day)
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"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.
|
||||
_ = 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
closeOutZoneLoss(pres, seated, zone, "zone")
|
||||
if !result.TimedOut {
|
||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
||||
forceExtractExpeditionForRunLoss(userID, lossCombatDeath)
|
||||
} else {
|
||||
forceExtractExpeditionForRunLoss(userID, "combat retreat")
|
||||
forceExtractExpeditionForRunLoss(userID, lossCombatRetreat)
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
|
||||
ob.WriteString(line)
|
||||
|
||||
@@ -323,7 +323,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
||||
// but only when this run is the active expedition's current run so
|
||||
// a standalone (non-expedition) stale run still reaps cleanly.
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what
|
||||
// the fan-out seams (digest, briefing, recap) want: a solo expedition yields
|
||||
// just the owner, so a caller can loop unconditionally.
|
||||
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
|
||||
members, err := partyMembers(expeditionID)
|
||||
// PartySeatKind is what a seat *is*. The three answers differ in ways that matter
|
||||
// at almost every seam, and conflating any two of them has already cost us a bug:
|
||||
// a companion is a seat but not a mouth and not a mailbox; a leader owns the
|
||||
// expedition row that everyone else references.
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return []id.UserID{id.UserID(ownerID)}, nil
|
||||
if len(rows) == 0 {
|
||||
// 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))
|
||||
for _, m := range members {
|
||||
out = append(out, id.UserID(m.UserID))
|
||||
out := make([]PartySeat, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var n int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
|
||||
expeditionID).Scan(&n)
|
||||
`SELECT COUNT(*) FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id <> ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&n)
|
||||
if err != nil {
|
||||
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))))
|
||||
}
|
||||
for _, m := range members {
|
||||
if isCompanionUser(m.UserID) {
|
||||
b.WriteString(companionRosterLine(exp.ID))
|
||||
continue
|
||||
}
|
||||
role := "member"
|
||||
if m.IsLeader() {
|
||||
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))
|
||||
if inviteWindowOpen(exp) {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -108,13 +108,45 @@ func seatedExpeditionFor(userID id.UserID) (*Expedition, error) {
|
||||
// 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
|
||||
// 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 {
|
||||
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 == "" {
|
||||
return nil
|
||||
}
|
||||
ids, err := partyMemberIDs(e.ID, e.UserID)
|
||||
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)
|
||||
return []id.UserID{id.UserID(e.UserID)}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") ==
|
||||
type SimRunner struct {
|
||||
P *AdventurePlugin
|
||||
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
|
||||
@@ -400,7 +405,42 @@ type SimCombatSummary struct {
|
||||
// SetSimIncludeTrace(true) has been called, and only on the LAST
|
||||
// combat per expedition (the boss room) to keep JSONL size bounded.
|
||||
// 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.
|
||||
@@ -516,6 +556,22 @@ func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneI
|
||||
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...)
|
||||
res.PartySize = len(roster)
|
||||
for _, m := range members {
|
||||
@@ -942,7 +998,7 @@ func simCombatSummaries(uid id.UserID) []SimCombatSummary {
|
||||
out = append(out, s)
|
||||
}
|
||||
if simIncludeTrace && len(out) > 0 {
|
||||
out[len(out)-1].Events = lastEvents
|
||||
out[len(out)-1].Events = simTraceEvents(lastEvents)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1038,6 +1094,18 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
|
||||
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)
|
||||
var dispatchErr error
|
||||
switch kind {
|
||||
@@ -1142,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
|
||||
// the wrong person and re-arm the wrong aura.
|
||||
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
if c == nil || sess == nil {
|
||||
if 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", ""
|
||||
}
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
@@ -1157,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) {
|
||||
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
||||
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
||||
@@ -1164,7 +1254,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
// otherwise never spends an L2 slot on it. Force the pick once
|
||||
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
||||
// 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
|
||||
}
|
||||
// Once a concentration aura is up, a competent caster maintains it and
|
||||
@@ -1172,7 +1262,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
// slot to re-arm the same aura — so the picker excludes concentration
|
||||
// spells while one is active.
|
||||
auraActive := st.ConcentrationDmg > 0
|
||||
if id := simPickSpell(c, uid, auraActive); id != "" {
|
||||
if id := simPickSpell(c, sess, seat, uid, auraActive); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
}
|
||||
@@ -1207,14 +1297,14 @@ func simMartialFirstClass(class DnDClass) bool {
|
||||
// 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
|
||||
// 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 {
|
||||
return ""
|
||||
}
|
||||
if st.BuffSpiritProc > 0 {
|
||||
return ""
|
||||
}
|
||||
known, err := listKnownSpells(uid)
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -1228,7 +1318,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
||||
if !prepared {
|
||||
return ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
const simMaxSlot = 5
|
||||
for sl := 2; sl <= simMaxSlot; sl++ {
|
||||
pair, ok := slots[sl]
|
||||
@@ -1261,12 +1351,111 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
||||
// - Among feasible candidates, prefer higher slot level (preserves
|
||||
// high-slot supremacy and burns the big slots first); tie-break on
|
||||
// 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 {
|
||||
return ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
type cand struct {
|
||||
id string
|
||||
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")
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
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
|
||||
equip map[EquipmentSlot]*AdvEquipment
|
||||
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 (
|
||||
@@ -58,6 +63,10 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
builds []seatBuild
|
||||
seated []id.UserID
|
||||
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 {
|
||||
@@ -69,6 +78,29 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
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)
|
||||
if err != nil || advChar == nil {
|
||||
if leader {
|
||||
@@ -120,6 +152,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
// 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.
|
||||
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)
|
||||
|
||||
players = append(players, player)
|
||||
@@ -127,8 +164,21 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
||||
})
|
||||
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)
|
||||
dumpCombatEventsIfDebug(
|
||||
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 {
|
||||
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
|
||||
// per heal_item event this seat fired). Cheapest-tier first.
|
||||
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
|
||||
@@ -193,6 +256,12 @@ func (p *AdventurePlugin) closeOutZoneWin(
|
||||
) (leaderDrop string, downed []id.UserID) {
|
||||
party := len(seated) > 1
|
||||
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 {
|
||||
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
|
||||
if i == 0 {
|
||||
@@ -217,6 +286,12 @@ func (p *AdventurePlugin) closeOutZoneWin(
|
||||
// fight.
|
||||
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
|
||||
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 {
|
||||
markAdventureDead(uid, deathSource, zone.Display)
|
||||
killed = append(killed, uid)
|
||||
|
||||
9
main.go
9
main.go
@@ -13,6 +13,7 @@ import (
|
||||
"gogobee/internal/bot"
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/dreamclient"
|
||||
"gogobee/internal/peteclient"
|
||||
"gogobee/internal/plugin"
|
||||
"gogobee/internal/util"
|
||||
"gogobee/internal/version"
|
||||
@@ -55,6 +56,11 @@ func main() {
|
||||
}
|
||||
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:
|
||||
// masdevice (default) — MAS OAuth device grant over /sync.
|
||||
// appservice — as_token auth over Synapse transaction push.
|
||||
@@ -97,6 +103,9 @@ func main() {
|
||||
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
|
||||
// /sync refreshes presence implicitly). Safe before the listener — outbound only.
|
||||
sess.StartPresence(ctx)
|
||||
|
||||
Reference in New Issue
Block a user