mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
31 Commits
fix-automi
...
6d402343e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d402343e6 | ||
|
|
ab2bcf0c59 | ||
|
|
65a48b4bd7 | ||
|
|
3563519db1 | ||
|
|
4e0b8a298c | ||
|
|
c368257896 | ||
|
|
f7ddbf8858 | ||
|
|
99daac3c2b | ||
|
|
94077a47ae | ||
|
|
8fc5a82b83 | ||
|
|
2ab6e7843a | ||
|
|
ced75786b9 | ||
|
|
1cbd68a718 | ||
|
|
e377e0c85c | ||
|
|
7c379b298c | ||
|
|
a533e106c2 | ||
|
|
2482433896 | ||
|
|
da398cf674 | ||
|
|
e98029e6ac | ||
|
|
064ecb1848 | ||
|
|
bae83271fe | ||
|
|
c9128fb0d6 | ||
|
|
055f07d3c0 | ||
|
|
27b9de5936 | ||
|
|
01c2cb2f0b | ||
|
|
d538f91cf7 | ||
|
|
f4a4c0d30b | ||
|
|
1634bb1970 | ||
|
|
ae7ff38996 | ||
|
|
b42beec348 | ||
|
|
0c4c4757d3 |
@@ -48,7 +48,7 @@ func main() {
|
|||||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||||
|
|
||||||
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
|
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")
|
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||||
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
||||||
@@ -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")
|
party = flag.Int("party", 1, "seat a party of N (1 = solo, the historical path). Followers are invited on Day 1 and buy their own supplies")
|
||||||
partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)")
|
partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)")
|
||||||
|
companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.")
|
||||||
|
|
||||||
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
|
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
|
||||||
)
|
)
|
||||||
@@ -88,7 +89,7 @@ func main() {
|
|||||||
includeLog = *logFlag
|
includeLog = *logFlag
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses)
|
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ func main() {
|
|||||||
return
|
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
|
// 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string) {
|
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string, companion string) {
|
||||||
dir := dataDir
|
dir := dataDir
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
var err error
|
var err error
|
||||||
@@ -172,7 +173,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
|
|||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers)
|
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers, companion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if res != nil {
|
if res != nil {
|
||||||
if !includeLog {
|
if !includeLog {
|
||||||
@@ -199,7 +200,7 @@ type matrixJob struct {
|
|||||||
rep int
|
rep int
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses string) {
|
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) {
|
||||||
cs := splitNonEmpty(classes)
|
cs := splitNonEmpty(classes)
|
||||||
ls := parseLevels(levels)
|
ls := parseLevels(levels)
|
||||||
zs := splitNonEmpty(zones)
|
zs := splitNonEmpty(zones)
|
||||||
@@ -230,7 +231,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < jobs; i++ {
|
for i := 0; i < jobs; i++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses)
|
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
for _, j := range work {
|
for _, j := range work {
|
||||||
@@ -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()
|
defer wg.Done()
|
||||||
for j := range in {
|
for j := range in {
|
||||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
||||||
@@ -275,6 +276,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
|||||||
if partyClasses != "" {
|
if partyClasses != "" {
|
||||||
args = append(args, "-party-classes", partyClasses)
|
args = append(args, "-party-classes", partyClasses)
|
||||||
}
|
}
|
||||||
|
if companion != "" {
|
||||||
|
args = append(args, "-companion", companion)
|
||||||
|
}
|
||||||
if trace {
|
if trace {
|
||||||
args = append(args, "-trace")
|
args = append(args, "-trace")
|
||||||
}
|
}
|
||||||
@@ -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)
|
runner, err := plugin.NewSimRunner(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init runner: %w", err)
|
return nil, fmt.Errorf("init runner: %w", err)
|
||||||
@@ -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")
|
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
|
||||||
members = append(members, muid)
|
members = append(members, muid)
|
||||||
}
|
}
|
||||||
|
runner.Companion = companion
|
||||||
return runner.RunPartyExpedition(uid, members, zone, cap, days)
|
return runner.RunPartyExpedition(uid, members, zone, cap, days)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
gogobee_adventure_announcement.md
Normal file
120
gogobee_adventure_announcement.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Adventure — the big update (2026-07-09 → 2026-07-11)
|
||||||
|
|
||||||
|
62 commits, ~31k lines, 7 phases. All merged to `main` and live in prod as of
|
||||||
|
2026-07-11. Working-tree doc; do not commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one-line version
|
||||||
|
|
||||||
|
Adventure stopped being a solo grind. You can now bring friends, fight each
|
||||||
|
other, fight the world together, prestige past the level cap, and there's an
|
||||||
|
actual story running underneath it — and Pete reports on all of it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Co-op — you can bring people now (N3)
|
||||||
|
|
||||||
|
The headline. The combat engine was 1-versus-1 at its core; it was rewritten
|
||||||
|
into an N-player engine with real initiative, and every seat gets its own turn.
|
||||||
|
|
||||||
|
- `!expedition invite @user` — leader, Day 1 only
|
||||||
|
- `!expedition accept` / `decline` / `party` / `leave`
|
||||||
|
- Everyone buys their own supply pack. Everyone acts on their own turn.
|
||||||
|
- The enemy's action economy scales to the party, so four people don't trivialize
|
||||||
|
a room.
|
||||||
|
|
||||||
|
## 2. Fight each other (N6)
|
||||||
|
|
||||||
|
- `!duel @user [stake]`, then `!duel accept` / `decline` / `status`.
|
||||||
|
Staked, and **nobody dies** — it's a bout, not a mugging.
|
||||||
|
- `!rivals` for your record, `!rivals board` for room-wide standings.
|
||||||
|
|
||||||
|
## 3. Fight the world together (N6)
|
||||||
|
|
||||||
|
- `!adventure worldboss` — the Siege. One communal bout a day; `worldboss fight`
|
||||||
|
to take your swing at it. Everybody's damage counts toward the same health bar.
|
||||||
|
|
||||||
|
## 4. The Shadow (N6)
|
||||||
|
|
||||||
|
- `!adventure shadow` — a rival adventurer who runs the dungeon on their own
|
||||||
|
schedule whether you play or not, and you can check how your run stacks up
|
||||||
|
against theirs.
|
||||||
|
|
||||||
|
## 5. The Town (N4)
|
||||||
|
|
||||||
|
Housing finally pays off, and the room has a civic life.
|
||||||
|
|
||||||
|
- **T3 payoffs** — trophy hall + workshop; a long rest at home now grants a
|
||||||
|
**well-rested** buff that scales with your house tier.
|
||||||
|
- **T4 Estate vault** — `!adventure vault` / `vault store <item>` / `vault take
|
||||||
|
<item>`. 10 protected slots. Safe from `!sell all`, safe from combat.
|
||||||
|
- **A second pet** at Tier 4 — it shows up on its own.
|
||||||
|
- **Gifting** — `!give <item> @user`. Small handling fee to the community pot.
|
||||||
|
- **Registries** — `!town` (civic pride, housing street, pet showcase),
|
||||||
|
`!graveyard` (recent deaths), `!rivals`.
|
||||||
|
|
||||||
|
## 6. Prestige and a world that moves (N7)
|
||||||
|
|
||||||
|
- **Renown** — XP past L20 no longer evaporates. It accrues into a Renown rank
|
||||||
|
that shows on your `!sheet` and gets announced when it ticks up.
|
||||||
|
- **The Omen** — one rotating world modifier per week. TwinBee tells you what's
|
||||||
|
in the air in the morning DM.
|
||||||
|
- **Seasonal events** — holiday weeks re-skin the day's events and flavor.
|
||||||
|
- **Achievements** — a whole Renown wing added to `!achievements`.
|
||||||
|
|
||||||
|
## 7. The chase (N2)
|
||||||
|
|
||||||
|
- **Tempering** — `!adventure temper` pushes a magic item one rarity step higher
|
||||||
|
at the blacksmith. Euros plus a T5 material (one per clear — *that's* the real
|
||||||
|
gate).
|
||||||
|
- **Arena seasons** — `!arena leaderboard` is now the current season; champions
|
||||||
|
get titles. `!arena stats` keeps your lifetime record.
|
||||||
|
|
||||||
|
## 8. Story (N5 — the Hollow King)
|
||||||
|
|
||||||
|
- **`!adventure journal`** — recover campaign pages through the zones and read
|
||||||
|
them back. Bosses now have epilogues, and there's a real finale.
|
||||||
|
- NPC arcs land as DMs mid-run — Misty, Robbie, and Thom all have somewhere to
|
||||||
|
go now.
|
||||||
|
|
||||||
|
## 9. Backtracking (C5)
|
||||||
|
|
||||||
|
- **`!revisit <N>`** — walk back one room from the Path strip in `!map` for +1
|
||||||
|
threat. Blocked mid-fight, at a fork, and when it's too hot to double back.
|
||||||
|
|
||||||
|
## 10. Pete reports on it (Pete Adventure News)
|
||||||
|
|
||||||
|
Guild events — zone firsts, deaths, achievements — are now narrated into a live
|
||||||
|
news feed by Pete, the deadpan announcer.
|
||||||
|
|
||||||
|
- `news.parodia.dev/adventure` (feed, permalinks, daily digest)
|
||||||
|
- Live beats in the games room
|
||||||
|
- `!news` to read; `!news optout` if you'd rather not be named.
|
||||||
|
|
||||||
|
## 11. Restoration + plumbing (N1, unglamorous but real)
|
||||||
|
|
||||||
|
- The drop systems Phase R orphaned are wired back up; milestone rewards that
|
||||||
|
were stubs actually pay out.
|
||||||
|
- Lottery ticket sales fund the community pot.
|
||||||
|
- Fixed outside Adventure but shipped alongside: URL previews stopped dropping
|
||||||
|
thumbnails, and the bot stopped reminting its cross-signing identity on every
|
||||||
|
restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Do NOT put in the announcement
|
||||||
|
|
||||||
|
- **Secret rooms and the cross-zone keys** (Sunken Sigil, Underforge Seal). These
|
||||||
|
are discovery content — no command, found by walking. Announcing them deletes
|
||||||
|
the discovery.
|
||||||
|
- **The NPC buffs** (Misty/Arina). Hidden mechanics by design. Say the NPCs have
|
||||||
|
arcs; do not say what befriending them *does*.
|
||||||
|
- Numbers, rolls, and rarity math generally — house style is verbs and outcomes,
|
||||||
|
not crunch.
|
||||||
|
|
||||||
|
## Watch list (first live exercise in prod)
|
||||||
|
|
||||||
|
Renown, the Omen, seasonal events, the Hollow King arc, the world boss and duels
|
||||||
|
have **never fired against real players before today**. Expect the first firings
|
||||||
|
to be the real test.
|
||||||
84
gogobee_adventure_announcement_copy.md
Normal file
84
gogobee_adventure_announcement_copy.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Announcement copy — your voice (draft)
|
||||||
|
|
||||||
|
Working-tree doc; do not commit.
|
||||||
|
|
||||||
|
Register: a person telling their friends what they built. No press-release
|
||||||
|
cadence, no bold-hook-per-paragraph, no claiming things are new when they're
|
||||||
|
expanded (houses, the town, and the story all already existed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Long version (post to the games room)
|
||||||
|
|
||||||
|
Big Adventure update went out. The main thing: you can play it with other people
|
||||||
|
now.
|
||||||
|
|
||||||
|
`!expedition invite @user` on day one of a run and they come with you. Everyone
|
||||||
|
buys their own supplies and takes their own turn, and enemies get more to do when
|
||||||
|
the party's bigger, so a group of four doesn't just walk through everything. This
|
||||||
|
was most of the work — the combat engine only ever knew how to run one player
|
||||||
|
against one monster, so it had to be rebuilt.
|
||||||
|
|
||||||
|
And if nobody's around, you can hire Pete. `!expedition hire` and he'll fill
|
||||||
|
whatever role you're short of — he fights his own turns, takes no loot and no XP,
|
||||||
|
and files a story about it afterwards. He's one guy, so if he's already out with
|
||||||
|
someone else you'll be told he's on assignment.
|
||||||
|
|
||||||
|
While I was in there: you can duel each other for a stake (`!duel @user 500`,
|
||||||
|
nobody dies), and there's a world boss once a day that everyone chips at together
|
||||||
|
(`!adventure worldboss`). There's also a rival called the Shadow who runs the
|
||||||
|
dungeon whether or not you log in — `!adventure shadow` to see how you compare.
|
||||||
|
|
||||||
|
Houses do a bit more at the top end. Tier 3 gets you a trophy hall and a
|
||||||
|
workshop, and resting at home now leaves you well-rested — better the higher your
|
||||||
|
tier. Tier 4 gets you a vault that `!sell all` can't touch, and a second pet turns
|
||||||
|
up on its own.
|
||||||
|
|
||||||
|
The town's grown too. You can hand items to each other with `!give`, and `!town`,
|
||||||
|
`!graveyard` and `!rivals` will show you how everyone else is doing.
|
||||||
|
|
||||||
|
L20 isn't the cap anymore — XP past it turns into Renown, which shows on your
|
||||||
|
sheet and keeps climbing. And there's a new modifier every week that changes how
|
||||||
|
the week plays; I'll tell you what's in the air in the morning.
|
||||||
|
|
||||||
|
The story's been expanded into a proper campaign that runs through the zones. A
|
||||||
|
king was emptied out so the crown could keep wearing him; he's been dead a long
|
||||||
|
time and the debt is still accruing. `!adventure journal` to read what you've
|
||||||
|
picked up. Misty, Robbie and Thom all have somewhere to go now, too.
|
||||||
|
|
||||||
|
Loot chasing got a couple of new levers: `!adventure temper` pushes a magic item
|
||||||
|
one rarity step up at the blacksmith, and the arena runs in seasons now, with
|
||||||
|
titles for whoever's on top when one closes.
|
||||||
|
|
||||||
|
Pete's reporting on all of it at news.parodia.dev/adventure — zone firsts, deaths,
|
||||||
|
achievements — plus live beats in here. `!news` to read it, `!news optout` if you
|
||||||
|
don't want your name in it.
|
||||||
|
|
||||||
|
Also `!revisit <N>` if you want to double back a room, and a pile of things that
|
||||||
|
were quietly broken now aren't.
|
||||||
|
|
||||||
|
It's all live. Let me know what's broken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Short version (one-screen post)
|
||||||
|
|
||||||
|
Big Adventure update, and the main thing is you can play it with other people now.
|
||||||
|
|
||||||
|
- **Parties** — `!expedition invite @user`. Real co-op, own turns, enemies scale
|
||||||
|
to the group. The combat engine got rebuilt for it.
|
||||||
|
- **Hire Pete** — `!expedition hire`. He fills the role you're missing, takes no
|
||||||
|
cut, and writes about it after.
|
||||||
|
- **Duels** — `!duel @user <stake>`. Staked, nobody dies.
|
||||||
|
- **World boss** — `!adventure worldboss`. One communal bout a day.
|
||||||
|
- **The Shadow** — `!adventure shadow`. A rival who plays whether you do or not.
|
||||||
|
- **Houses** — more at the top end: well-rested buff, a vault at T4, a second pet.
|
||||||
|
- **The town's grown** — `!give`, `!town`, `!graveyard`, `!rivals`.
|
||||||
|
- **Renown** — L20 isn't the cap anymore.
|
||||||
|
- **A weekly modifier** — the world changes shape every week.
|
||||||
|
- **The story's now a campaign** — a dead king, a crown still wearing him, a debt
|
||||||
|
still accruing. `!adventure journal`.
|
||||||
|
- **Tempering + arena seasons** — `!adventure temper`, `!arena`.
|
||||||
|
- **Pete's on the beat** — news.parodia.dev/adventure. `!news`.
|
||||||
|
|
||||||
|
All live. Let me know what's broken.
|
||||||
100
gogobee_appservice_migration.md
Normal file
100
gogobee_appservice_migration.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# GogoBee → Appservice Auth Migration (multi-session)
|
||||||
|
|
||||||
|
Goal: move TwinBee's Matrix auth from the **MAS OAuth 2.0 device grant** (working
|
||||||
|
stopgap) to the **full appservice transaction/push model** — the MAS-durable end
|
||||||
|
state: no human login, no MFA, no consent, `as_token` never expires, scales at
|
||||||
|
485+ rooms. The device grant stays behind `AUTH_MODE=masdevice` as instant
|
||||||
|
rollback.
|
||||||
|
|
||||||
|
Why the *previous* appservice attempt failed: it was a hybrid (as_token + `/sync`),
|
||||||
|
and Synapse forbids appservice-namespace users from `/sync`. This migration
|
||||||
|
replaces `/sync` with Synapse→bot transaction push + MSC3202/MSC2409 E2EE
|
||||||
|
extensions, which is the supported path in mautrix v0.28.1.
|
||||||
|
|
||||||
|
Keep this doc working-tree only — do NOT commit (see feedback_dont_commit_plan_mds).
|
||||||
|
|
||||||
|
## Key facts established (investigation, 2026-07-03)
|
||||||
|
|
||||||
|
- **HEAD 20d1f92 is MISLABELED**: commit msg says "device grant" but it only
|
||||||
|
deleted registration.yaml.example; committed client.go is still the FAILED
|
||||||
|
appservice+/sync hybrid. The real device-grant (masauth.go + client.go rewrite)
|
||||||
|
is UNCOMMITTED working tree — that's what runs in prod. Backed up to scratchpad.
|
||||||
|
- mautrix v0.28.1 fully supports appservice E2EE: transaction carries
|
||||||
|
`to_device`/`device_lists`/`device_one_time_keys_count` (stable + msc2409/msc3202
|
||||||
|
unstable field names); `CreateDeviceMSC4190`; cryptohelper `MSC4190=true` mints
|
||||||
|
the device (no /login). HEAD's old appservice code already had auth+crypto
|
||||||
|
correct — only the /sync event loop was wrong.
|
||||||
|
- To-device processing gates on `Registration.EphemeralEvents` (yaml
|
||||||
|
`receive_ephemeral: true`) — `appservice/http.go:133`.
|
||||||
|
- Encrypted rooms: must feed `m.room.encryption`/`m.room.member` to the client's
|
||||||
|
StateStore via `mautrix.UpdateStateStore(...)` + `mach.HandleMemberEvent`, or the
|
||||||
|
bot sends plaintext into encrypted rooms / can't share keys.
|
||||||
|
- Both hosts SSH-reachable: `reala@parodia.dev` (Synapse/MAS/Authentik docker),
|
||||||
|
`reala@192.168.1.212` (millenia, bot in screen). Retired registration at
|
||||||
|
`/home/reala/matrix/compose/synapse/gogobee-registration.yaml.retired-20260703-223425`.
|
||||||
|
|
||||||
|
## Decisions (user, 2026-07-03)
|
||||||
|
|
||||||
|
- Cutover: **AUTH_MODE flag** (keep device grant as rollback), not full replace.
|
||||||
|
- Synapse changes: **I apply + restart, confirm right before restart**.
|
||||||
|
|
||||||
|
## Session 1 — bot-side code (local, no prod risk) ✅ DONE (build+vet green)
|
||||||
|
|
||||||
|
- [x] `Config`: AuthMode + appservice fields (RegistrationPath, ListenHost/Port,
|
||||||
|
HomeserverDomain).
|
||||||
|
- [x] `internal/bot/session.go`: `Session` unifying both modes (OnEventType/Run/
|
||||||
|
Stop); `NewSession(cfg)` dispatches on AuthMode; masdevice path wraps existing
|
||||||
|
device-grant NewClient + DefaultSyncer + sync loop (verbatim).
|
||||||
|
- [x] `internal/bot/appservice.go`: LoadRegistration → CreateFull → BotClient;
|
||||||
|
cryptohelper MSC4190 device create; ep.OnOTK/OnDeviceList + 9 crypto to-device
|
||||||
|
types → crypto machine; EventEncrypted decrypt+redispatch; StateStore
|
||||||
|
population (encryption + members) via lazy per-room resolveRoom + UpdateStateStore
|
||||||
|
on member/encryption state; HTTP listener via as.Start() in goroutine.
|
||||||
|
- [x] `main.go`: NewSession + sess.OnEventType + sess.Run; env AUTH_MODE,
|
||||||
|
AS_REGISTRATION, AS_LISTEN_HOST, AS_LISTEN_PORT, HOMESERVER_DOMAIN; envInt helper.
|
||||||
|
- [x] `.env.example` documented; go.mod tidied (zerolog now direct dep).
|
||||||
|
- [x] `go build`/`go vet` green (CGO=1 -tags goolm).
|
||||||
|
|
||||||
|
New files: internal/bot/session.go, internal/bot/appservice.go. NOT yet committed.
|
||||||
|
|
||||||
|
### Known correctness risks to verify in Session 3
|
||||||
|
- **resolveRoom is lazy on inbound encrypted events only.** Scheduled/broadcast
|
||||||
|
posts into an encrypted room the bot hasn't received from yet would send
|
||||||
|
plaintext (StateStore unresolved). Mitigation for S3: prewarm the configured
|
||||||
|
rooms (BROADCAST_ROOMS, GAMES_ROOM, ESTEEMED_ROOM, MOD_ADMIN_ROOM, MINIFLUX_*)
|
||||||
|
at startup, or resolve all joined rooms once. Interactive `!` commands are
|
||||||
|
reply-driven so lazy resolve covers them.
|
||||||
|
- Whether crypto machine successfully shares keys after ReplaceCachedMembers +
|
||||||
|
device fetch (FetchKeys) — the make-or-break, only testable against live Synapse.
|
||||||
|
- MSC4190 device creation must succeed against MAS-fronted Synapse (untested end
|
||||||
|
to end; HEAD's old code used the same path but was never verified past whoami).
|
||||||
|
|
||||||
|
## Session 2 — Synapse infra on parodia (touches live homeserver)
|
||||||
|
|
||||||
|
- [ ] Restore gogobee-registration.yaml: set `url:` = bot tailnet addr, add
|
||||||
|
`receive_ephemeral: true`, `org.matrix.msc3202: true`, keep
|
||||||
|
`io.element.msc4190: true`, `rate_limited: false`.
|
||||||
|
- [ ] homeserver.yaml `experimental_features`: msc2409_to_device_messages_enabled,
|
||||||
|
msc3202_device_masquerading, msc3202_transaction_extensions,
|
||||||
|
msc4190_device_management. Wire `app_service_config_files`.
|
||||||
|
- [ ] Determine bot's headscale tailnet address on parodia (Synapse must reach the
|
||||||
|
bot's ListenPort). Confirm routing.
|
||||||
|
- [ ] Backup homeserver.yaml + registration; **confirm before restart**; restart
|
||||||
|
matrix-synapse; check it comes back for all users.
|
||||||
|
|
||||||
|
## Session 3 — cutover + E2EE verification on millenia
|
||||||
|
|
||||||
|
- [ ] Copy registration to bot host; set AUTH_MODE=appservice + AS_* env.
|
||||||
|
- [ ] rsync source + build in place (CGO=1 -tags goolm), restart screen session.
|
||||||
|
Remove empty local data/gogobee.db first (empty_local_db_wipes_prod).
|
||||||
|
- [ ] Verify: transactions arrive (listener logs), whoami OK, MSC4190 device made,
|
||||||
|
bot responds to `!` command in a PLAINTEXT room.
|
||||||
|
- [ ] Verify E2EE: bot decrypts an incoming encrypted-room message AND its reply
|
||||||
|
decrypts for a human client (encrypt-out works). This is the make-or-break.
|
||||||
|
- [ ] If broken: AUTH_MODE=masdevice, restart → back on device grant. Then triage.
|
||||||
|
|
||||||
|
## Cleanup (after proven)
|
||||||
|
|
||||||
|
- Prune stale @twinbee devices, orphan MAS client, retire masauth.go or leave
|
||||||
|
behind the flag. Fix HEAD mislabel (the eventual commit should actually contain
|
||||||
|
device-grant + appservice, not the stale hybrid).
|
||||||
244
gogobee_boredom_plan.md
Normal file
244
gogobee_boredom_plan.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# Bored Adventurers — autonomous expedition starts
|
||||||
|
|
||||||
|
**Premise.** A player who stops tending their adventurer doesn't stop having an
|
||||||
|
adventurer. After 24h with no Adventure action, the character gets restless and
|
||||||
|
leaves on an expedition by itself. It goes with the gear it already has and the
|
||||||
|
cheapest supplies it can afford. If you never arm it, the results are bad — and
|
||||||
|
that's the mechanic, not a bug.
|
||||||
|
|
||||||
|
## Why this is small
|
||||||
|
|
||||||
|
The autopilot is already almost fully autonomous. `runAutopilotWalkDriven`
|
||||||
|
(expedition_autorun.go:168) walks rooms, drives elite and boss fights on the real
|
||||||
|
turn engine, auto-camps, auto-harvests, rolls day rollovers, and auto-picks a
|
||||||
|
fork after 8h of silence. Nothing in that path ever needs a human.
|
||||||
|
|
||||||
|
The one thing that still needs a human is the *start*: `!expedition start <zone>
|
||||||
|
<loadout>` (dnd_expedition_cmd.go:281). So this feature is one new ticker that
|
||||||
|
performs that start, plus a clock to decide when.
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Idle clock | **Any action against Adventure**, from any interface — not Matrix chatter, not autopilot writes |
|
||||||
|
| Threshold | 24h idle → the adventurer leaves. No warning DM. |
|
||||||
|
| Re-trigger | After a bored run ends, another 24h of silence starts another. Forever. |
|
||||||
|
| Zone | Easiest zone whose **level band** contains the character. Never trivial low-tier farming. |
|
||||||
|
| "Raid-shaped" | = **multi-region** zones (underdark, dragons_lair, abyss_portal). Avoided — *unless* they're the only at-band option (L13+), then allowed. |
|
||||||
|
| Supplies | **Lean** loadout (cheapest). Broke → no run. |
|
||||||
|
| Gear | Never bought, never equipped. This is the whole point. |
|
||||||
|
|
||||||
|
## §1 — The clock: `last_player_action_at`
|
||||||
|
|
||||||
|
The existing timestamps are all unusable:
|
||||||
|
|
||||||
|
- `player_meta.last_active_at` — auto-bumped on **every character save**
|
||||||
|
(adventure_character.go:584). The autopilot saves constantly, so a bored
|
||||||
|
character would refresh its own idle clock and never qualify again.
|
||||||
|
- `user_stats.updated_at` — bumped on every Matrix message in any room
|
||||||
|
(stats.go:103). That's chat presence, not *game* action. Rejected per the
|
||||||
|
"any action against Adventure, not just in Matrix" rule.
|
||||||
|
- `daily_activity` / `loadAdvDailyActivity` — unified across
|
||||||
|
`dnd_expedition_log`, so the autopilot's own walk entries count as player
|
||||||
|
activity. Same self-refresh problem.
|
||||||
|
|
||||||
|
**New:** `player_meta.last_player_action_at DATETIME`, written *only* by
|
||||||
|
`markPlayerAction(uid)` — a direct UPDATE, never routed through
|
||||||
|
`saveAdvCharacter`, so no ticker or autopilot path can touch it. Any future
|
||||||
|
non-Matrix entry point (web, Pete, API) calls the same helper.
|
||||||
|
|
||||||
|
Stamped from `AdventurePlugin.OnMessage` when the message is a real player
|
||||||
|
action: an `!` command in `advActionCommands`, or a reply to a pending
|
||||||
|
interaction (`p.pending`, adventure.go:889 — shop/blacksmith/pet prompts).
|
||||||
|
|
||||||
|
`advActionCommands` is the 47 names actually routed in `OnMessage`'s if-chain.
|
||||||
|
Note `Commands()` (adventure.go:158) only registers **28** — it's a help
|
||||||
|
surface, not a routing table, and is missing `expedition`, `zone`, `fight`,
|
||||||
|
`camp`, `extract`, `resume`, and more. A test greps the `p.IsCommand(ctx.Body,
|
||||||
|
"…")` literals out of adventure.go and asserts each one is in the set, so a
|
||||||
|
future command can't silently fall out of the clock.
|
||||||
|
|
||||||
|
## §2 — Zone pick: `pickBoredomZone(level, uid)`
|
||||||
|
|
||||||
|
`zonesForLevel` is wrong for this: it filters on **tier only** and ignores the
|
||||||
|
`LevelMin`/`LevelMax` fields that already exist on `ZoneDefinition` — which is
|
||||||
|
why a level-1 player can currently walk into a T3 zone. The boredom picker uses
|
||||||
|
the bands instead.
|
||||||
|
|
||||||
|
1. Candidates = zones where `LevelMin <= level <= LevelMax`.
|
||||||
|
2. Prefer **non**-multi-region (`IsMultiRegionZone`, dnd_expedition_region.go:135).
|
||||||
|
Among those, lowest `Tier` — the "easiest at-level" rule.
|
||||||
|
3. Ties broken by **least-recently-started by this player**, so a bored
|
||||||
|
adventurer rotates zones instead of grinding one forever.
|
||||||
|
4. If step 2 is empty, retry allowing multi-region. This only bites at L13+,
|
||||||
|
where `dragons_lair` and `abyss_portal` are the only at-band zones and both
|
||||||
|
are multi-region.
|
||||||
|
5. Still empty → no run.
|
||||||
|
|
||||||
|
Worked examples:
|
||||||
|
|
||||||
|
| Level | At-band | Picked |
|
||||||
|
|---|---|---|
|
||||||
|
| 5 | forest_shadows, sunken_temple (T2), manor, underforge (T3) | a T2 zone |
|
||||||
|
| 10 | underdark (multi), feywild_crossing | feywild_crossing |
|
||||||
|
| 12 | underdark (multi), feywild (T4), dragons_lair (multi, T5) | feywild_crossing |
|
||||||
|
| 15 | dragons_lair (multi), abyss_portal (multi) | dragons_lair — fallback fires |
|
||||||
|
|
||||||
|
## §3 — The run
|
||||||
|
|
||||||
|
Ticker `expeditionBoredomTicker`, 30m interval, skips the ambient quiet window.
|
||||||
|
|
||||||
|
Candidates: `player_meta` rows, `alive = 1`, `COALESCE(last_player_action_at,
|
||||||
|
created_at) < now-24h`, and `last_boredom_at` NULL or older than the 24h
|
||||||
|
cooldown. (COALESCE on `created_at` so a character made and never played still
|
||||||
|
qualifies, but not on its first tick.)
|
||||||
|
|
||||||
|
Per-user skips, all reusing existing guards: resting lockout, seated on someone
|
||||||
|
else's party, active expedition, resumable (extracted, pending `!resume`)
|
||||||
|
expedition, active zone run, active combat session.
|
||||||
|
|
||||||
|
CAS-claim `last_boredom_at` **before** starting, so a crash mid-start can't loop.
|
||||||
|
Then: `loadoutPurchase(zone.Tier, LoadoutLean)` → balance check → shared
|
||||||
|
`beginExpedition` helper → DM.
|
||||||
|
|
||||||
|
A zero-supply start is impossible (`SupplyPurchase.Validate`,
|
||||||
|
dnd_expedition_supplies.go:270, hard-rejects it), so the cheapest bored run costs
|
||||||
|
50 coins at T1 and 250 at T5. Can't afford it → no run, one DM (rate-limited by
|
||||||
|
the cooldown we already claimed).
|
||||||
|
|
||||||
|
## §4 — Refactor: `beginExpedition`
|
||||||
|
|
||||||
|
`expeditionCmdStart` is prompt-layer (:282–309) followed by a reusable
|
||||||
|
transaction (:369–424): balance check, debit, holiday/omen freebies,
|
||||||
|
`startExpedition`, `ensureRegionRun`, log, refund-on-failure. Extract that second
|
||||||
|
half into `beginExpedition(uid, c, zone, purchase, reason)` so the command and
|
||||||
|
the boredom ticker share one code path and one refund story.
|
||||||
|
|
||||||
|
## §5 — Why the results will be bad (no work required)
|
||||||
|
|
||||||
|
This all already exists; the feature just points it at neglected characters:
|
||||||
|
|
||||||
|
- The autopilot **never buys or equips anything** — verified, no `buy`/`equip`/
|
||||||
|
`Debit` calls anywhere in expedition_autorun.go, expedition_autocamp.go, or
|
||||||
|
dnd_expedition_autopilot_harvest.go. Stale gear stays stale.
|
||||||
|
- Lean supplies deplete fast → **Rationing** (−1 attack/skill) at 25% →
|
||||||
|
**Severe** (−2, long rests blocked) at 10% → **Starvation** at 0, which forces
|
||||||
|
an extract with a **20% coin tax** on the haul (dnd_expedition_cycle.go:140).
|
||||||
|
- The boss-safety gate holds the autopilot out of boss rooms below 80% HP, so an
|
||||||
|
under-geared character stalls, camps, and burns supplies it doesn't have.
|
||||||
|
|
||||||
|
The neglected adventurer grinds mediocre, half-starved runs on rusting gear and
|
||||||
|
comes home taxed. Exactly the intent.
|
||||||
|
|
||||||
|
## §6 — Streaks: no credit for a run you didn't ask for
|
||||||
|
|
||||||
|
Bored runs write `dnd_expedition_log` entries, which `loadAdvDailyActivity`
|
||||||
|
counts as player activity — so without care a bored run would **suppress the
|
||||||
|
idle-shame DM and preserve the daily streak** for someone who hasn't shown up in
|
||||||
|
weeks.
|
||||||
|
|
||||||
|
Two halves, and only one needed fixing:
|
||||||
|
|
||||||
|
- **Bumping** the streak was already safe. `engaged` reduces to
|
||||||
|
`LastActionDate == today|yesterday` (the `CombatActionsUsed`/
|
||||||
|
`HarvestActionsUsed` clauses are dead — nothing in the tree increments them),
|
||||||
|
and `LastActionDate` is only set by `markActedToday`, which is called only
|
||||||
|
from real player commands. `beginExpedition` deliberately does not call it.
|
||||||
|
- **Shielding** was not. Both hold branches in `midnightReset` (the activity
|
||||||
|
oracle, and the active-expedition safety net) would spare an absent player.
|
||||||
|
|
||||||
|
Fixed with `dnd_expedition.boredom` + `isBoredomDriven(uid, now)`, which requires
|
||||||
|
*both* halves: the player hasn't acted in 24h **and** their most recent
|
||||||
|
expedition is one the adventurer started for itself. A manually-started
|
||||||
|
expedition still holds the streak while the autopilot walks it — that behavior is
|
||||||
|
untouched. Reading the *most recent* expedition rather than the active one keeps
|
||||||
|
it honest on the night after a bored run ends, when its logs would otherwise
|
||||||
|
still read as player activity.
|
||||||
|
|
||||||
|
## §7 — Robbie
|
||||||
|
|
||||||
|
Robbie sweeps every non-slotted inventory item daily (ores, fish, junk,
|
||||||
|
treasure — `robbieQualifyingItems` takes all of them unconditionally) and pays
|
||||||
|
`Value / 4`. The autopilot deposits harvested loot straight into
|
||||||
|
`adventure_inventory` (dnd_expedition_harvest.go:478, dnd_zone_loot.go), so:
|
||||||
|
|
||||||
|
**Robbie funds the boredom loop.** Bored run → auto-harvested junk → Robbie
|
||||||
|
converts it to coins → pays for the next lean pack → forever. The "they go broke
|
||||||
|
and stop" poverty floor does not exist. Abandoned characters are perpetual
|
||||||
|
motion, and that is the accepted design.
|
||||||
|
|
||||||
|
What was *not* accepted was the noise: Robbie posts a public room announcement
|
||||||
|
per visit, so every abandoned character would file a daily bulletin about
|
||||||
|
somebody who stopped playing weeks ago. He now visits and pays silently for idle
|
||||||
|
players (adventure_robbie.go), gated on the same clock.
|
||||||
|
|
||||||
|
## §8 — The bug this nearly shipped with
|
||||||
|
|
||||||
|
`playerIsIdle` originally scanned `COALESCE(last_player_action_at, created_at)`
|
||||||
|
straight into a `*time.Time`, and `lastExpeditionByZone` scanned
|
||||||
|
`MAX(start_date)`. **modernc.org/sqlite rebuilds a `time.Time` from the column's
|
||||||
|
declared type, and both `COALESCE()` and `MAX()` erase it** — the value comes
|
||||||
|
back a string and the Scan fails.
|
||||||
|
|
||||||
|
`lastExpeditionByZone` failed loudly. `playerIsIdle` failed *open*: an unreadable
|
||||||
|
row counts as idle, so it returned `true` for every player alive, including one
|
||||||
|
who had acted an hour earlier. Shipped, that would have marched the entire server
|
||||||
|
into dungeons at once and silenced Robbie for everybody.
|
||||||
|
|
||||||
|
The unit tests missed it at first because they ran against empty tables, so the
|
||||||
|
scan never executed. Both now select the declared columns and fold in Go.
|
||||||
|
|
||||||
|
Note the asymmetry: `loadBoredomCandidates` uses `COALESCE` too and is fine —
|
||||||
|
it's evaluated *inside* SQL and never scanned. Only Go-side scans through an
|
||||||
|
aggregate or COALESCE lose the type.
|
||||||
|
|
||||||
|
## §9 — Status
|
||||||
|
|
||||||
|
Built, vetted, full suite green. **Not deployed, not committed.** Working tree
|
||||||
|
carries: db.go (3 columns), expedition_boredom.go (new), expedition_boredom_test.go
|
||||||
|
+ expedition_boredom_clock_test.go (new), dnd_expedition_cmd.go (beginExpedition
|
||||||
|
extraction), adventure.go (clock stamp + ticker), adventure_scheduler.go (idle
|
||||||
|
reaper gate), adventure_robbie.go (silent for idle), twinbee_expedition_flavor.go
|
||||||
|
(ExpeditionBoredomStart).
|
||||||
|
|
||||||
|
## §10 — Verified end-to-end against prod data
|
||||||
|
|
||||||
|
`internal/plugin/exercise_boredom_prod_test.go` (build tag `prodexercise`), run
|
||||||
|
against a throwaway copy of the live DB:
|
||||||
|
|
||||||
|
```
|
||||||
|
GOGOBEE_PROD_DB_DIR=<dbcopy> go test -tags prodexercise -run TestExerciseBoredomProd -v ./internal/plugin/
|
||||||
|
```
|
||||||
|
|
||||||
|
PASS. What it actually proved, on real characters with real gear:
|
||||||
|
|
||||||
|
- **The clock gates the sweep.** A control player stamped as having acted an hour
|
||||||
|
ago was not a candidate and was not charged; every player who departed passed
|
||||||
|
`playerIsIdle`. This is the assertion that would have caught the COALESCE
|
||||||
|
fail-open bug (§8), which is why the control arm is not optional.
|
||||||
|
- **Real departures.** 3 of 5 candidates left. `@holymachina` (L20 cleric) →
|
||||||
|
`dragons_lair`, `@quack` (L4 mage) → `forest_shadows`, `@camcast` (L1) →
|
||||||
|
`crypt_valdris`. The other two stayed home for the right reasons and the test
|
||||||
|
names them: one has no character, one never finished creation.
|
||||||
|
- **L20 into the raid, as designed.** `dragons_lair` is the only zone in
|
||||||
|
`@holymachina`'s band, so the fallback fired and the departure DM carried the
|
||||||
|
raid warning. Exactly the decision that was locked.
|
||||||
|
- **It bought nothing.** Coins debited equal the lean pack to the coin (250 at
|
||||||
|
T5, 50 at T1/T2), and the equipment fingerprint is byte-identical across the
|
||||||
|
run.
|
||||||
|
- **Flagged and cooled.** `boredom = 1` on every row, `isBoredomDriven` true, and
|
||||||
|
a second tick 30 minutes later charged nobody a second time.
|
||||||
|
- **The autopilot walks it.** Rooms accumulate across segments.
|
||||||
|
|
||||||
|
And it drew blood on the first run: **`@quack` (L4 mage, stale gear) died four
|
||||||
|
rooms in.** That is the mechanic, working, on the very first exercise. Death is
|
||||||
|
`Kill()` — `alive = 0`, 6h respawn, auto-revived by the scheduler ticker
|
||||||
|
(adventure_scheduler.go:72), and `loadBoredomCandidates` requires `alive = 1`, so
|
||||||
|
a dead adventurer sits out its six hours and then gets restless again. Death
|
||||||
|
pauses the loop; it doesn't end it.
|
||||||
|
|
||||||
|
## §11 — Status
|
||||||
|
|
||||||
|
Built, vetted, full suite green, exercised end-to-end against prod data.
|
||||||
|
**Not deployed, not committed.**
|
||||||
787
gogobee_code_review_followups.md
Normal file
787
gogobee_code_review_followups.md
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
# Code review follow-ups — `n1-restoration` (2026-07-10)
|
||||||
|
|
||||||
|
Review scope: `git diff main...HEAD`, 99 files / ~16.7k lines (N1, N2, R1, R2, N3 P1–P7).
|
||||||
|
Method: 8 finder angles → dedup → 1 verifier per correctness candidate.
|
||||||
|
Baseline before and after: `go build`, `go vet`, `go test ./internal/...` all green.
|
||||||
|
|
||||||
|
Working-tree doc — **do not commit** (per project convention on `gogobee_*.md`).
|
||||||
|
|
||||||
|
**Status.** Fixes 1–5 shipped in `1f21156`. Deferred items **A** and **B** fixed
|
||||||
|
2026-07-10 (`d76c63b`); A turned out to have a deeper root cause and surfaced
|
||||||
|
three new findings, **M**, **N** and **O**. Item **C** fixed 2026-07-10 — its
|
||||||
|
premise was wrong and the bug underneath it was worse (the seven-day resume
|
||||||
|
window was never enforced; see below). Item **M** fixed 2026-07-10 — two of its
|
||||||
|
three claims were wrong (the mage hooks *do* run on the turn path); the one real
|
||||||
|
defect, Grim Harvest, had a different cause than recorded. Items **D**, **I**,
|
||||||
|
**L**, **N** fixed. Item **K** verified 2026-07-10 — the anchor gap is a real,
|
||||||
|
confirmed narrowing, left as an owner design call (no code change). Item **H**
|
||||||
|
measured 2026-07-10 and **declined** — its "per-message cascade" premise was
|
||||||
|
overstated, the hot path is already optimized, and the proposed
|
||||||
|
`MessageContext` thread-through is disproportionate churn with a staleness
|
||||||
|
regression surface.
|
||||||
|
|
||||||
|
**All correctness work is committed.** Everything remaining is deliberate, not a
|
||||||
|
defect: **E** (intentional, roster-size death rule), **F** (`grantAutorunGrace`
|
||||||
|
stopgap, deferred to R5 by design), **G** (party-combat DB chatter — a perf
|
||||||
|
refactor whose only safe fix is a fight-lifetime roster cache, i.e. the same
|
||||||
|
staleness surface H was declined for; deferred), **H** (declined, above),
|
||||||
|
**J** (`!sell` anchor — PLAUSIBLE, presence == any chat, left per convention),
|
||||||
|
**K** (owner design call, above), **O** (already fixed as a side-effect of A;
|
||||||
|
only the sim re-baseline is pending, which is operational).
|
||||||
|
|
||||||
|
**Method note.** Both C and M were mis-diagnosed in the same way: the finder read
|
||||||
|
one call site, concluded "this is the only caller", and wrote it down. Verify the
|
||||||
|
callers before planning the fix.
|
||||||
|
|
||||||
|
**Third pass (2026-07-10, `88c5fcd`).** `/code-review high --fix` over the pushed
|
||||||
|
follow-up stack (`git diff origin/main...HEAD`, 7 commits). Five fixes shipped;
|
||||||
|
they harden the item-C extraction work and the item-N Misty ordering rather than
|
||||||
|
opening new ground. See "Third pass" below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before you commit: split the formatting from the fixes
|
||||||
|
|
||||||
|
A repo-wide `gofmt -w ./internal ./cmd` ran after the review. It reformatted
|
||||||
|
**105 files that have nothing to do with these findings** — they were already
|
||||||
|
non-conforming on `main` (struct-field alignment, mostly), and gofmt is
|
||||||
|
semantics-preserving, so nothing changed behaviourally.
|
||||||
|
|
||||||
|
Net state: **117 files dirty, only 13 carry review fixes.** Commit them apart, or
|
||||||
|
the fixes become unreviewable inside a wall of realignment.
|
||||||
|
|
||||||
|
The 13 fix-bearing files:
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/plugin/adventure.go * internal/plugin/dnd_expedition_supplies.go *
|
||||||
|
internal/plugin/adventure_arena_season.go internal/plugin/dnd_zone_cmd.go *
|
||||||
|
internal/plugin/combat_cmd.go internal/plugin/expedition_party.go
|
||||||
|
internal/plugin/combat_party_turn.go internal/plugin/expedition_party_cmd.go
|
||||||
|
internal/plugin/dnd_expedition.go * internal/plugin/expedition_party_resolve.go
|
||||||
|
internal/plugin/dnd_expedition_combat.go internal/plugin/zone_combat_party.go
|
||||||
|
internal/plugin/combat_engine_party_test.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus one new file: `internal/plugin/zone_combat_party_casualty_test.go`.
|
||||||
|
|
||||||
|
The four marked `*` carry **both** a fix and gofmt realignment
|
||||||
|
(`adventure.go`, `dnd_expedition.go`, `dnd_expedition_supplies.go`,
|
||||||
|
`dnd_zone_cmd.go`), so a clean split needs `git add -p` on those rather than a
|
||||||
|
straight per-file split. The other nine are fix-only and can be staged whole.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixed in this pass
|
||||||
|
|
||||||
|
| # | File | Defect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 1 | `combat_party_turn.go` | Settle-to-terminal dropped the entire close-out |
|
||||||
|
| 2 | `expedition_party_cmd.go` | Party member permanently soft-locked on leader extract |
|
||||||
|
| 3 | `expedition_party_cmd.go` | Lost update on the shared supply pool |
|
||||||
|
| 4 | `dnd_expedition_combat.go` | Elite harvest interrupt granted no elite loot |
|
||||||
|
| 5 | `adventure_arena_season.go` | Transient failure permanently lost a season crown |
|
||||||
|
|
||||||
|
Plus cleanups: deleted dead `partySurvivors`; collapsed the `zoneCombatRoster`
|
||||||
|
alias into `fightRoster`; `partyCasualtyLine` now uses `joinNames`; consolidated
|
||||||
|
four copies of the expedition column projection into `expeditionSelectCols`;
|
||||||
|
corrected two false doc comments on `applyDailyBurn`/`applyDailyBurnP`; `replyDM`
|
||||||
|
no longer sends a blank DM.
|
||||||
|
|
||||||
|
### 1. Close-out dropped when the settle turns lethal — the worst one
|
||||||
|
|
||||||
|
`beginCombatTurn` calls `settleCombatSession` to resolve a phase the engine owes
|
||||||
|
(a fight parked mid enemy-turn after a restart). That settle can end the fight.
|
||||||
|
The old code then saw `!sess.IsActive()` and returned "you're not in a fight."
|
||||||
|
|
||||||
|
The terminal status was already persisted, so nothing ever paid the party out:
|
||||||
|
no XP, no loot, no `markAdventureDead`, no `abandonZoneRun`, no
|
||||||
|
`forceExtractExpeditionForRunLoss`. The reaper cannot rescue it either —
|
||||||
|
`listExpiredCombatSessions` filters `status = 'active'`, and the session is now
|
||||||
|
terminal. The win or the death simply evaporates.
|
||||||
|
|
||||||
|
The `!fight` start path and the reaper both already do settle-then-`closePartyRound`;
|
||||||
|
`beginCombatTurn` was the one settle site that skipped it. Now it closes out and
|
||||||
|
announces, matching them.
|
||||||
|
|
||||||
|
### 2. Member soft-locked when the leader extracts and walks away
|
||||||
|
|
||||||
|
`seatedExpeditionFor` (the guard) spans `status IN ('active','extracting')`.
|
||||||
|
`expeditionForMember` (what `!expedition leave` resolved through) filtered
|
||||||
|
`status = 'active'` only. During the leader's 7-day extracting limbo a member was
|
||||||
|
therefore refused any new adventure by the guard, and told "No active expedition"
|
||||||
|
by the very command the guard pointed them at. Nothing sweeps stale `extracting`
|
||||||
|
rows, and only the *leader* can trigger the transition that clears them — so if
|
||||||
|
the leader quit, the member was stuck forever with no self-service recovery.
|
||||||
|
|
||||||
|
`!expedition leave` now resolves the seat through `seatedExpeditionFor`, so the
|
||||||
|
exit sees every state the gate sees. (`seatedExpeditionFor` already excludes
|
||||||
|
leaders, so they still fall through to the `!extract` message.)
|
||||||
|
|
||||||
|
### 3. Lost update on the shared supply pool
|
||||||
|
|
||||||
|
`updateSupplies` rewrites `supplies_json` wholesale. `expeditionCmdAccept` folded
|
||||||
|
a new member's packs onto an `exp` snapshot read ~60 lines earlier, with no lock.
|
||||||
|
Handlers run one goroutine per event (mautrix `AsyncHandlers`, never overridden),
|
||||||
|
so two invitees accepting at once genuinely interleave: both read pool `P`, one
|
||||||
|
writes `P+a`, the other `P+b` — a member's packs vanish, or spent SU is
|
||||||
|
resurrected by the day-burn tick.
|
||||||
|
|
||||||
|
Note `advUserLock` cannot fix this: it is keyed by sender, so two members take
|
||||||
|
two different mutexes. Added `advExpeditionLock(expID)` and re-read the pool
|
||||||
|
under it. **This closes accept-vs-accept only** — see deferred item B.
|
||||||
|
|
||||||
|
### 4. Elite harvest interrupt paid standard loot
|
||||||
|
|
||||||
|
`runHarvestInterrupt` computed `elite := kind == InterruptElite`, used it to pick
|
||||||
|
an elite enemy and to pick elite narration, then passed a hardcoded `false` as
|
||||||
|
`isElite` to `closeOutZoneWin`. Since `dropZoneLoot` gates on `isBoss || isElite`,
|
||||||
|
beating an elite interrupt skipped the masterwork roll and used the 1.0 standard
|
||||||
|
treasure weight instead of 2.0 — while the identical elite fought via `!zone`
|
||||||
|
paid out correctly.
|
||||||
|
|
||||||
|
Now passes the live `elite`. The param only flows into `dropZoneLoot`, so this
|
||||||
|
adds the masterwork roll and doubles treasure weight for surviving seats; threat,
|
||||||
|
XP, and narration are untouched. A small reward buff on a fight players already
|
||||||
|
win, consistent with lifting trailers rather than nerfing.
|
||||||
|
|
||||||
|
### 5. A failed season crown was lost permanently, not retried
|
||||||
|
|
||||||
|
`arenaSeasonRollover` logged-and-`continue`d when `recordArenaSeasonTitle`
|
||||||
|
failed, then called `db.MarkJobCompleted` unconditionally. `JobCompleted`
|
||||||
|
short-circuits every future run for that quarter, so a transient SQLite `BUSY`
|
||||||
|
meant the crown was never awarded — ever.
|
||||||
|
|
||||||
|
`recordArenaSeasonTitle` is idempotent (`arena_season_titles` has
|
||||||
|
`PRIMARY KEY (season, kind)`, insert is `ON CONFLICT DO NOTHING`) and a past
|
||||||
|
season's data is frozen, so retrying is safe. The job is now marked complete only
|
||||||
|
when no crown failed. The "no entrants" path still closes the season correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixed in the second pass (2026-07-10) — items A and B
|
||||||
|
|
||||||
|
### A. Turn-based combat skipped achievements and post-combat subclass state
|
||||||
|
|
||||||
|
The root cause was one level deeper than this doc originally recorded, and it is
|
||||||
|
worth writing down because the surface symptom was misleading.
|
||||||
|
|
||||||
|
`buildZoneCombatants` used to call `applyArmedAbility`, which applies an ability's
|
||||||
|
mods **and then clears `c.ArmedAbility` and saves the sheet**. The turn engine
|
||||||
|
calls that same builder again on *every* `!attack` / `!cast` / `!consume`. So in a
|
||||||
|
turn-based fight:
|
||||||
|
|
||||||
|
- Round 1's build fired the ability, spent the resource, cleared the arm flag.
|
||||||
|
- Round 2+ rebuilt the character, found `ArmedAbility == ""`, and produced a
|
||||||
|
combatant with **none of the ability's mods**.
|
||||||
|
|
||||||
|
A Berserker paid stamina and got exactly one round of `BerserkerRage`,
|
||||||
|
`RageMeleeDmg`, `PhysicalResistRage`, `FrenzyDmgBonus`. Every entry in
|
||||||
|
`dndActiveAbilities` had the same shape. `mods.BerserkerRage` was not merely
|
||||||
|
unread at close-out — by then it no longer existed.
|
||||||
|
|
||||||
|
Fixed by splitting arming into its two halves:
|
||||||
|
|
||||||
|
- `consumeArmedAbility(c)` — the mutation. Disarms, saves, returns the id. Runs
|
||||||
|
**once**, at fight start.
|
||||||
|
- `applyAbilityByID(c, id, mods)` — pure. No DB write, no disarm. Safe on every
|
||||||
|
rebuild. (No ability's `Apply` writes to the character, so this is genuinely pure.)
|
||||||
|
- `armAbilityForFight(c, mods)` — consume + apply, for the auto-resolve callers
|
||||||
|
that build and fight in one breath.
|
||||||
|
|
||||||
|
`buildZoneCombatants` now takes the already-consumed `armed` id and re-applies it.
|
||||||
|
The id rides on `ActorStatuses.ArmedAbility`, seeded per seat at fight start
|
||||||
|
(`seedActorOneShots`), so `partyCombatantsForSession` reproduces the ability on
|
||||||
|
every rebuild and the close-out can still see that a rage fired.
|
||||||
|
|
||||||
|
On top of that, the close-out itself: `postCombatBookkeeping(...)` in
|
||||||
|
`combat_bridge.go` now carries the achievements + subclass persistence, and all
|
||||||
|
four close-outs route through it — `runDungeonCombat`, `runZoneCombatRoster`,
|
||||||
|
`finishCombatSession`, `finishPartyCombatSession`. The turn-based pair reach it
|
||||||
|
via `postCombatBookkeepingForSeat`, which rebuilds the `CombatResult` the hooks
|
||||||
|
need from the persisted session (`seatCombatResult`) and re-derives the
|
||||||
|
fight-start mods (`seatFightStartMods`).
|
||||||
|
|
||||||
|
It fires on **every** terminal status, not just a win — a Berserker who rages and
|
||||||
|
loses is still exhausted, which is what auto-resolve always did.
|
||||||
|
|
||||||
|
Also fixed in passing: `buildFightSeats` and `runZoneCombatRoster` consumed the
|
||||||
|
armed ability *before* the checks that could sit a seat out, so a downed member
|
||||||
|
was disarmed for a fight they never joined. The refusals now run first.
|
||||||
|
|
||||||
|
**Balance note.** Turn-based Berserkers now keep rage for the whole fight instead
|
||||||
|
of one round. That is a buff, but it is the buff the player already paid for.
|
||||||
|
The class-balance corpus measures the auto-resolve path, so it is unmoved.
|
||||||
|
|
||||||
|
New tests: `internal/plugin/combat_armed_ability_test.go` (9 cases) —
|
||||||
|
purity/repeatability of `applyAbilityByID`, once-only consume, rage surviving
|
||||||
|
rebuilds, the sat-out member keeping their ability, per-seat statuses isolation,
|
||||||
|
and exhaustion on both a manual win and a manual loss.
|
||||||
|
|
||||||
|
The Grim Harvest half of this item was closed separately — see item **M**.
|
||||||
|
|
||||||
|
### B. The other six supply writers raced
|
||||||
|
|
||||||
|
All six now go through `withExpeditionSupplies` (`dnd_expedition.go`), which takes
|
||||||
|
`advExpeditionLock(expID)`, re-reads the expedition, hands the closure the fresh
|
||||||
|
row, and persists what it returns:
|
||||||
|
|
||||||
|
`dnd_expedition_cycle.go` (`nightRolloverBurn`, forage + burn in one write),
|
||||||
|
`dnd_expedition_milestone.go` (`grantTwoWeeksCache`),
|
||||||
|
`dnd_expedition_region_cmd.go` (`advanceToNextRegion` transit burn),
|
||||||
|
`dnd_expedition_camp.go` (`campPitch`), `expedition_autocamp.go`
|
||||||
|
(`pitchAutopilotCamp`), `expedition_ambient.go` (pack-rat drain).
|
||||||
|
|
||||||
|
Fix #3's hand-rolled lock in `expeditionCmdAccept` was folded onto the same
|
||||||
|
helper. Seven call sites, no nesting — verified none of the closure callees
|
||||||
|
re-enter the lock (`sync.Mutex` is not reentrant).
|
||||||
|
|
||||||
|
`expedition_sim.go:1421` is left alone on purpose: the sim is single-threaded and
|
||||||
|
takes no plugin locks.
|
||||||
|
|
||||||
|
The atomic-`json_set`-delta alternative is still the better long-term shape (it
|
||||||
|
would drop the lock entirely) but it couples `updateSupplies` to the blob format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Third pass (2026-07-10, `88c5fcd`) — `/code-review high` on the follow-up stack
|
||||||
|
|
||||||
|
Reviewed `git diff origin/main...HEAD` (the 7 pushed follow-up commits) with 8
|
||||||
|
finder angles → dedup → 1 verifier per candidate. The removed-behavior angle came
|
||||||
|
back empty: the close-out refactor (item D), `parseMenuIndex` (item I), the
|
||||||
|
GrimHarvest stash (item M), and the `endRunOnLoss`/`applyOwnerWinEffects`/
|
||||||
|
`grantSeatWinXP` extractions all verified behavior-preserving. Findings clustered
|
||||||
|
in the new item-C extraction guard, plus one ordering nuance in the item-N Misty
|
||||||
|
wiring. Five fixed:
|
||||||
|
|
||||||
|
1. **The extraction guard fell open on a DB error.** `expeditionCmdStart`'s
|
||||||
|
resumable-guard did `switch n, err := partySize(pending.ID)` and gated the
|
||||||
|
party-blocking refusal on `case err == nil && n > 1`. A transient roster-read
|
||||||
|
error skipped both cases, fell through, and started a new expedition on top of
|
||||||
|
the still-seated party — the exact orphaning the guard exists to prevent, in
|
||||||
|
the one situation (a DB hiccup on the roster read) where it matters. Now checks
|
||||||
|
`extractionLapsed` first (pure, no DB call on the reap path) and treats a
|
||||||
|
`partySize` error as "assume occupied → refuse". Also drops the wasted `COUNT`
|
||||||
|
the old switch-init ran on the lapsed-reap path.
|
||||||
|
|
||||||
|
2. **The start-path lapsed reap unseated members silently.** When a leader with a
|
||||||
|
lapsed party extraction ran `!expedition start`, the reap called
|
||||||
|
`completeExpedition(...Failed)` — which frees the roster — with no DM, unlike
|
||||||
|
the hourly sweeper and `!expedition abandon`, which both notify. Extracted
|
||||||
|
`reapLapsedExtraction(e)` (reap + notify the audience) as the single
|
||||||
|
reap-with-notify path; the sweeper and the start-path reap both route through
|
||||||
|
it, so a member is never silently unseated by whichever path reaches the row
|
||||||
|
first.
|
||||||
|
|
||||||
|
3. **Misty's crowd swung before the concentration pulse.** Item N's new
|
||||||
|
`stepRoundEnd` seat-loop ran `seatEndOfRound` (crowd revenge, which can end the
|
||||||
|
fight) *before* the pre-existing concentration-aura tick. Concentration is
|
||||||
|
turn-engine-only (no auto-resolve counterpart), so this was a within-engine
|
||||||
|
ordering call, not a cross-engine divergence — but a caster whose lingering
|
||||||
|
aura would kill the enemy that round could be dropped by crowd revenge first,
|
||||||
|
contradicting the concentration block's own "a lethal pulse settles the fight"
|
||||||
|
intent. Moved the Misty seat-loop to *after* the concentration tick: a lethal
|
||||||
|
pulse now wins via `finish(CombatStatusWon)` before the end-of-round crowd
|
||||||
|
swing; on any round the pulse doesn't end the fight, both procs fire exactly as
|
||||||
|
before. (Owner-approved reorder — it was reported as a balance call and the
|
||||||
|
answer was "move it".)
|
||||||
|
|
||||||
|
4. **Fourth copy of the event-log scan.** `seatCombatResult` hand-rolled a
|
||||||
|
`misty_heal` scan loop. Promoted the test-only `hasAction(events, action)`
|
||||||
|
helper to production (`combat_session_build.go`), used it there, and removed the
|
||||||
|
duplicate test definition.
|
||||||
|
|
||||||
|
5. **New `dnd_`-prefixed file.** Renamed `dnd_expedition_extract_sweep_test.go` →
|
||||||
|
`expedition_extract_sweep_test.go`, per the no-new-`dnd_`-names rule (the diff's
|
||||||
|
three other new test files already avoided the prefix).
|
||||||
|
|
||||||
|
**Reported, not fixed** (deliberate):
|
||||||
|
|
||||||
|
- **Misty ordering** was the only correctness-shaped finding; the other angles
|
||||||
|
agreed the stack was clean. Two lower items were left: `dnd_setup.go`'s
|
||||||
|
respec/wipe disbands an extracted party with no member DM — but the disband is
|
||||||
|
intended (item C hole #3) and the missing DM is outside the reviewed lines; and
|
||||||
|
`abandonExpedition` re-queries the row the caller already resolved — the
|
||||||
|
divergence a finder posited isn't reachable (the extracted-row branch only runs
|
||||||
|
when no active row exists), so the extra indexed SELECT was left alone.
|
||||||
|
|
||||||
|
Full plugin suite green before and after. No auto-resolve path touched, so the
|
||||||
|
class-balance corpus and `combat_characterization.golden` do not move.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred — real, not fixed
|
||||||
|
|
||||||
|
### ~~M. Mage subclass spell hooks never run in turn-based combat~~ — TWO-THIRDS MIS-STATED. Fixed 2026-07-10.
|
||||||
|
|
||||||
|
**The premise was wrong.** `applyMageSubclassSpellHooks` has three non-test
|
||||||
|
callers, not one. `resolveTurnSpell` (`dnd_spell_combat.go:341`) has called it
|
||||||
|
since Phase 13 shipped per-round casting (`5cd343a`, 2026-05-14).
|
||||||
|
|
||||||
|
So on the turn-based surface:
|
||||||
|
|
||||||
|
- **Empowered Evocation** (L7, +INT to evocation damage) — *always worked.*
|
||||||
|
- **Evocation Overchannel** (L10, ×1.5 on a 1st–5th-level slot) — *always worked.*
|
||||||
|
|
||||||
|
Both only move `mods.SpellPreDamage`, which `resolveTurnSpell` returns as
|
||||||
|
`out.EnemyDamage`. Nothing was lost.
|
||||||
|
|
||||||
|
**Grim Harvest was the only real defect**, and its cause was narrower than "the
|
||||||
|
hooks don't run": the hooks ran, wrote `mods.GrimHarvestSlot` — and
|
||||||
|
`resolveTurnSpell` dropped that local `CombatModifiers` on the floor, because
|
||||||
|
`turnSpellOutcome` had no field to carry it out. A Necromancy Mage who killed
|
||||||
|
with a spell in a manual fight never healed.
|
||||||
|
|
||||||
|
The stash cannot ride on fight-start mods the way it does in auto-resolve: the
|
||||||
|
spell is cast *mid*-fight, and the turn engine rebuilds its combatants every
|
||||||
|
round. So it rides where every other mid-fight fact rides — the casting seat's
|
||||||
|
`ActorStatuses`:
|
||||||
|
|
||||||
|
1. `turnSpellOutcome` gained `GrimHarvestSlot` / `GrimHarvestNecrotic`.
|
||||||
|
2. `castActionForSeat` parks them on `actorStatusesPtr(seat)` when a cast lands.
|
||||||
|
`snapshotActor` starts from the prior snapshot, so they survive `commit()`
|
||||||
|
untouched, exactly as `ArmedAbility` does.
|
||||||
|
3. `seatFightStartMods` reads them back at close-out, in place of the comment
|
||||||
|
that used to explain why they were permanently zero.
|
||||||
|
|
||||||
|
**And the killing-blow check was wrong for this surface.** `grimHarvestHeal`
|
||||||
|
scanned for the **first** `spell_cast` event and refused the heal if the enemy
|
||||||
|
survived it. Auto-resolve casts once, pre-combat, so first == last there; a
|
||||||
|
turn-based mage casts every round, so an opening cantrip that left the enemy
|
||||||
|
standing vetoed the heal the round-3 killing spell had earned. It now scans for
|
||||||
|
the **last** `spell_cast` — provably identical on the auto-resolve path, which is
|
||||||
|
why the golden corpus does not move.
|
||||||
|
|
||||||
|
Each damaging cast overwrites the stash, so it always describes the seat's most
|
||||||
|
recent landed spell — the only one that can have been lethal. A miss stashes
|
||||||
|
nothing, and a stale stash is inert because the last `spell_cast` event then
|
||||||
|
shows the enemy alive.
|
||||||
|
|
||||||
|
**Balance note.** This is a caster buff on the manual surface — 2×/3×/4× slot
|
||||||
|
level in HP, once, on a spell kill, for L5+ Necromancy Mages. It is the buff the
|
||||||
|
subclass was written to have and auto-resolve already paid out. Consistent with
|
||||||
|
the standing "lift trailers" stance. The class-balance corpus measures
|
||||||
|
auto-resolve and is unmoved.
|
||||||
|
|
||||||
|
New tests: `internal/plugin/combat_grim_harvest_turn_test.go` (7 cases) — the
|
||||||
|
stash surviving `resolveTurnSpell`, nobody-else-stashes, last-cast-not-first,
|
||||||
|
weapon-kill-after-a-spell, the `snapshotActor` round-trip, the end-to-end heal at
|
||||||
|
`postCombatBookkeepingForSeat`, and per-seat isolation in a party fight.
|
||||||
|
|
||||||
|
### N. The NPC procs do not exist in the turn engine (RE-VERIFIED 2026-07-10 — conclusion holds, evidence corrected, scope wider) — the debuff exploit fixed 2026-07-10.
|
||||||
|
|
||||||
|
**Verified before planning**, after items C and M both turned out to rest on a
|
||||||
|
bad "only one caller" claim. This one survives, but two of its three sentences
|
||||||
|
did not.
|
||||||
|
|
||||||
|
**The `CrowdRevengeProc` exploit is now closed** (see the fix note at the end of
|
||||||
|
this item). Closing it pulled `MistyHealProc` in with it: both are round-end
|
||||||
|
effects, and the honest hook runs the pair rather than the debuff alone — a
|
||||||
|
one-sided hook would have been a second parallel sibling of the kind item D
|
||||||
|
warns about. So Misty's **heal** is now live on the turn path too. That is a
|
||||||
|
player-favourable discovery mechanic and fits the "lift trailers" stance, so it
|
||||||
|
ships together; only `SniperKillProc` stays turn-dead, because it is a
|
||||||
|
pre-combat one-shot with no round-end seam to hang on.
|
||||||
|
|
||||||
|
**Correction 1 — the location was wrong.** `MistyHealProc` is not read inside
|
||||||
|
`simulatePartyWithRNG`. It is read at `combat_engine_party.go:520` inside
|
||||||
|
`endOfRoundForSeat`, a *helper*, which is exactly the shape that made C and M
|
||||||
|
wrong. So the question had to be asked properly: `endOfRoundForSeat` has one
|
||||||
|
caller, `simulatePartyRound` (`:418`), which is the auto-resolve round. The turn
|
||||||
|
engine runs its own `stepRoundEnd` and never calls it. Conclusion stands.
|
||||||
|
`SniperKillProc` (`:102`, genuinely in `simulatePartyWithRNG`, a pre-combat
|
||||||
|
one-shot) has no turn-engine counterpart either.
|
||||||
|
|
||||||
|
**Correction 2 — the procs are built, then ignored.** `buildZoneCombatants` calls
|
||||||
|
`DerivePlayerStats` (`combat_session_build.go:61`), which sets
|
||||||
|
`mods.MistyHealProc` / `mods.SniperKillProc` (`combat_stats.go:180,184`) on every
|
||||||
|
turn-based combatant. The mods are present on the struct every single round. No
|
||||||
|
turn-engine code path reads either field. It is a dangling write, not a missing
|
||||||
|
build.
|
||||||
|
|
||||||
|
**Correction 3 — there is a third proc, and it cuts the other way.**
|
||||||
|
`CrowdRevengeProc` (`:473`) sits in the same `endOfRoundForSeat` and is equally
|
||||||
|
absent from the turn engine. That one is the Misty **debuff**. So the asymmetry
|
||||||
|
is not "manual fights are worse": a buffed player loses their buff by fighting
|
||||||
|
manually, and a debuffed player *escapes their debuff* by doing the same. The
|
||||||
|
debuff half is the one worth caring about, because it is exploitable and needs no
|
||||||
|
discovery to exploit — just fight everything with `!attack`.
|
||||||
|
|
||||||
|
**Not gaps** — checked and explained, so nobody re-files them:
|
||||||
|
|
||||||
|
- `PetAttackProc` and `SpiritWeaponProc` *are* reimplemented in the turn engine
|
||||||
|
(`petStrike`, `spiritWeaponStrike`), though they fire after a player action
|
||||||
|
rather than at round end.
|
||||||
|
- `EnvironmentProc` is absent because `turnCombatPhase` is a single flat "Duel"
|
||||||
|
phase with the proc at 0 — deliberate, not dropped.
|
||||||
|
- `HealItem`'s auto-heal never fires, but the turn engine seeds and snapshots
|
||||||
|
`HealChargesLeft` and gives the player `!consume` instead. Player-driven by
|
||||||
|
design, not a leak. (Worth confirming the charges aren't double-counted across
|
||||||
|
the two surfaces.)
|
||||||
|
|
||||||
|
**The achievement claim held when written; the fix moved it.** At the time,
|
||||||
|
`combat_sniper_kill` and `combat_misty_clutch` were both unreachable from a
|
||||||
|
manual kill and `seatCombatResult` hardcoded both flags false — so "six
|
||||||
|
achievements is really four" was correct. Wiring the heal changed that:
|
||||||
|
`seatCombatResult` now reads `MistyHealed` back off the `misty_heal` event on the
|
||||||
|
log (the same way `combat_pet_save` has always been detected), so
|
||||||
|
**`combat_misty_clutch` is reachable turn-based now** — it is five, not four.
|
||||||
|
`combat_sniper_kill` stays unreachable. The other four were always reachable:
|
||||||
|
`combat_near_death` (computed by `seatCombatResult`), `combat_pet_save` (the turn
|
||||||
|
engine does call `resolveEnemyAttack`, which emits `pet_whiff`),
|
||||||
|
`combat_death_save` (`trySave` in `stepRoundEnd`), and `combat_consumable_used`
|
||||||
|
(`combat_cmd.go:773`).
|
||||||
|
|
||||||
|
(Keep this internal. Per project convention these procs are hidden discovery
|
||||||
|
mechanics and must not surface in help text, player docs, or DMs.)
|
||||||
|
|
||||||
|
**Fix (2026-07-10).** Both Misty procs were hoisted out of `endOfRoundForSeat`
|
||||||
|
into shared helpers — `mistyCrowdRevenge` and `mistyHeal` — and a `seatEndOfRound`
|
||||||
|
hook that runs the pair in the order the auto-resolve engine does (crowd first,
|
||||||
|
then, if the seat survived, the heal). `endOfRoundForSeat` now calls the helpers;
|
||||||
|
the turn engine's `stepRoundEnd` calls `seatEndOfRound` per seat, after the
|
||||||
|
poison tick so a heal can answer the round's damage. Both helpers short-circuit
|
||||||
|
before touching the RNG when their proc is unarmed, so a character with no Misty
|
||||||
|
history draws exactly the dice it drew before — the sim corpus and
|
||||||
|
`combat_characterization.golden` do not move (full suite confirms).
|
||||||
|
|
||||||
|
`renderAllySeatEvent` gained a `crowd_revenge` case: an ally sees the damage land
|
||||||
|
(silence would read as HP vanishing) but not Misty's name, matching the
|
||||||
|
neighbouring `misty_heal`'s neutral "recovers N" — the grudge stays the owner's
|
||||||
|
own discovery.
|
||||||
|
|
||||||
|
New tests: `combat_misty_turn_test.go` (6 cases) — the crowd landing on the
|
||||||
|
manual surface (the exploit), a lethal crowd ending a solo fight, the heal firing
|
||||||
|
and capping, no heal for a seat the crowd just dropped, the no-RNG-when-unarmed
|
||||||
|
property that protects the golden file, and an end-to-end wiring test through
|
||||||
|
`advanceCombatSession` that reports `PlayerHP=40` (the shipped exploit) if the
|
||||||
|
`stepRoundEnd` call is removed.
|
||||||
|
|
||||||
|
### O. `trySimAutoArm` used to re-arm every round — sim baselines will move (NEW)
|
||||||
|
|
||||||
|
Before item A's fix, `trySimAutoArm` lived inside `buildZoneCombatants`. On the
|
||||||
|
turn-based path that meant: every rebuild re-armed the class-default ability,
|
||||||
|
**spent another point of the resource**, and re-applied it. A simulated Fighter
|
||||||
|
got a fresh Second Wind heal every single round until stamina ran dry; a Cleric,
|
||||||
|
a Healing Word.
|
||||||
|
|
||||||
|
`expedition-sim`'s `autoDriveCombat` drives `handleAttackCmd`, i.e. the
|
||||||
|
turn-based path — so this inflated every turn-based sim result. It now arms once
|
||||||
|
per fight, which is what `simAutoArmEnabled`'s own doc comment says it models
|
||||||
|
("a competent real player who would `!arm` Second Wind before each fight").
|
||||||
|
|
||||||
|
**Any expedition-sim corpus that involved turn-based combat is stale.** The
|
||||||
|
pending final L10/L12 re-baseline should be run after this change, not before.
|
||||||
|
The pure auto-resolve corpus (`SimulateCombat` / `simulateParty`) is unaffected —
|
||||||
|
that path always armed exactly once.
|
||||||
|
|
||||||
|
### ~~C. Members and leaders disagree about an expedition during `extracting`~~ — MIS-STATED; the real bug was worse. Fixed 2026-07-10.
|
||||||
|
|
||||||
|
**The premise was wrong.** `getActiveExpedition` — the *leader's* lookup — also
|
||||||
|
filters `status = 'active'`. During `extracting` the leader gets "no active
|
||||||
|
expedition" from `!expedition`, `!map`, `!camp` &c. exactly as the member does.
|
||||||
|
They already agree, and honestly so: nobody is standing in the dungeon. Widening
|
||||||
|
`expeditionForMember` would have made the member the only player who can see a
|
||||||
|
paused expedition. Not done, and it should not be.
|
||||||
|
|
||||||
|
Reading for it surfaced the real defect underneath.
|
||||||
|
|
||||||
|
**The seven-day resume window was never enforced.** `releaseParty` fires only on
|
||||||
|
a terminal status, and `dnd_expedition.go:428` justifies skipping it for
|
||||||
|
`extracting` by asserting "the roster is cleared when the resume window lapses
|
||||||
|
and the row flips to `failed`". Nothing did that. The only `extracting → failed`
|
||||||
|
transition in the tree was inside `handleResumeCmd` — a lazy expiry that runs
|
||||||
|
only when **the leader personally types `!resume`**.
|
||||||
|
|
||||||
|
So a leader who quit, forgot, or simply started a different expedition left the
|
||||||
|
row `extracting` **forever**. `releaseParty` never ran, every member stayed
|
||||||
|
seated, and `assertNotAdventuring` kept refusing them a run of their own. Fix #2
|
||||||
|
gave them `!expedition leave` as an escape, which is why this was a permanent
|
||||||
|
nag rather than a permanent soft-lock — but a player should not have to find it.
|
||||||
|
|
||||||
|
Three holes, all closed:
|
||||||
|
|
||||||
|
1. **No sweeper.** `sweepLapsedExtractions` (`dnd_expedition_extract.go`) reaps
|
||||||
|
every `extracting` row past `completed_at + 7d` through `completeExpedition`,
|
||||||
|
which releases the roster, and DMs the audience. Hourly ticker
|
||||||
|
(`expeditionExtractionSweepTicker`), plus a one-shot at `Start()` — a lapse
|
||||||
|
that happened while the bot was down is blocking those members *now*. The
|
||||||
|
audience is read before the close-out, since `completeExpedition` disbands the
|
||||||
|
roster `expeditionAudience` reads. `handleResumeCmd`'s lazy expiry stays, now
|
||||||
|
sharing the `extractionLapsed` predicate (which treats a NULL `completed_at`
|
||||||
|
as lapsed — it is the only clock the window has).
|
||||||
|
|
||||||
|
2. **The leader could orphan their own party.** `!expedition start` checked only
|
||||||
|
`getActiveExpedition`, so a leader could start fresh on top of an extracted
|
||||||
|
row. `!resume` resolves the *newest* `extracting` row, so the old one became
|
||||||
|
unreachable — un-resumable, un-abandonable, roster still held. It now refuses,
|
||||||
|
but only when that row has a roster (`partySize > 1`); a solo extraction
|
||||||
|
strands nobody, so walking away from one stays a normal thing to do. A lapsed
|
||||||
|
row is reaped in place and the start proceeds.
|
||||||
|
|
||||||
|
3. **The leader had no way out but to pay.** `expeditionCmdAbandon` resolved
|
||||||
|
through `activeExpeditionFor` and so could not see the extracted row it owns —
|
||||||
|
the leader had to buy a `!resume` just to abandon. Both it and
|
||||||
|
`abandonExpedition` now span `extracting` via `ownedLiveExpedition`, which
|
||||||
|
sorts active rows first so `expeditionCmdStart`'s run-spawn rollback still
|
||||||
|
tears down the row it just created rather than an older extracted one. This
|
||||||
|
also fixes `dnd_setup.go:394,449` for free: a leader who rerolled their
|
||||||
|
character used to strand their whole party.
|
||||||
|
|
||||||
|
Abandoning now DMs the members too, and says the true thing when the party is
|
||||||
|
standing in town rather than in the dungeon (supplies already spent, loot
|
||||||
|
already banked — not "supplies are forfeit").
|
||||||
|
|
||||||
|
New tests: `dnd_expedition_extract_sweep_test.go` (6 cases) — the sweep releasing
|
||||||
|
a stranded roster, leaving a live extraction alone, the NULL-`completed_at`
|
||||||
|
predicate, abandon reaching an extracted row and freeing the party, abandon
|
||||||
|
preferring the active row, and the no-live-row error.
|
||||||
|
|
||||||
|
Not covered: the two `expeditionCmdStart` / `expeditionCmdAbandon` refusals,
|
||||||
|
which sit above the DM boundary. Still the `dmSink` gap.
|
||||||
|
|
||||||
|
### ~~D. Two turn-based win close-outs must be kept in sync by hand~~ — effect-drift closed 2026-07-10.
|
||||||
|
|
||||||
|
`finishPartyWin` (`combat_party_finish.go`) was a hand-copied sibling of the
|
||||||
|
`CombatStatusWon` block in `finishCombatSession` (`combat_cmd.go`): mood
|
||||||
|
scan, kill record, threat, boss threat, XP + near-death, loot, TwinBee line,
|
||||||
|
continue hint. `finishPartyCombatSession` routes solo back to
|
||||||
|
`finishCombatSession`, so the two must stay equivalent forever, with nothing
|
||||||
|
forcing it. Item A was the first drift.
|
||||||
|
|
||||||
|
Item A's fix already pulled the *bookkeeping* out of both into
|
||||||
|
`postCombatBookkeepingForSeat`. This pass hoists the remaining duplicated
|
||||||
|
**effects** into three shared helpers in `combat_party_finish.go`, and routes
|
||||||
|
both close-outs through them:
|
||||||
|
|
||||||
|
- `applyOwnerWinEffects(owner, enemyID, elite) bool` — the zone-kill record,
|
||||||
|
room threat, and boss-defeat threat drop; returns `bossOnExpedition`. Fires
|
||||||
|
once, owner-scoped (a member owns neither row).
|
||||||
|
- `grantSeatWinXP(uid, hp, hpMax, monster, tier)` — near-death calc + XP grant.
|
||||||
|
Per seat (solo calls it once with seat 0).
|
||||||
|
- `endRunOnLoss(owner, runID, death)` — mood event (death only) + `abandonZoneRun`
|
||||||
|
+ `forceExtractExpeditionForRunLoss`, shared by both the Lost and Fled paths.
|
||||||
|
|
||||||
|
What deliberately stays divergent, so this was **not** a full `closeOutSeat`
|
||||||
|
merge: the player-facing text genuinely differs (solo "You finished at N/M HP"
|
||||||
|
vs party "The party stands"; the party's 4-way boss/hint split vs the solo
|
||||||
|
2-way), and the death-on-win rule is roster-size-dependent by design — solo
|
||||||
|
`finishCombatSession` never marks a winner dead, `finishPartyWin` marks a seat
|
||||||
|
that won from 0 HP dead. That is item E and must not be unified. So the effect
|
||||||
|
lists are now a single copy each; the text is not.
|
||||||
|
|
||||||
|
Full plugin suite green; no behavior change (helpers are extract-and-call).
|
||||||
|
|
||||||
|
Note this is the *only* place the party work chose a parallel sibling. Elsewhere
|
||||||
|
the branch is a genuine N-body generalization, not a bolt-on: `SimulateCombat` →
|
||||||
|
`simulatePartyWithRNG`, `runCombatRound` → `runPartyCombatRound`, `runZoneCombat`
|
||||||
|
→ `runZoneCombatRoster`, `RenderTurnRound` → `RenderPartyTurnRound`.
|
||||||
|
|
||||||
|
### E. Death-on-win depends on roster size — intentional, leave it
|
||||||
|
|
||||||
|
`closeOutZoneWin` marks a downed seat dead on a win only when `len(seated) > 1`.
|
||||||
|
A solo player who wins at 0 HP (retaliate aura kills the swinger on the killing
|
||||||
|
blow) survives; a party member in the same spot dies.
|
||||||
|
|
||||||
|
This is **deliberate and documented in place** (`zone_combat_party.go:183-188`):
|
||||||
|
unifying it would move the sim's outcome labels and force a re-baseline of
|
||||||
|
`testdata/combat_characterization.golden`. Not a defect. Listed so the next
|
||||||
|
person to find it doesn't "fix" it by accident.
|
||||||
|
|
||||||
|
### F. `grantAutorunGrace` is a stopgap (already tracked as R5)
|
||||||
|
|
||||||
|
`zone_revisit.go:172` stamps `last_autorun_at` to buy one `autoRunCooldown`
|
||||||
|
window after a `!revisit`, because the autopilot ticker has no concept of
|
||||||
|
position-vs-frontier and will otherwise march the party back out of the room they
|
||||||
|
just backtracked to. The code says so itself and defers the real fix to R5. It is
|
||||||
|
coupled to the exact value of `autoRunCooldown` and to the ticker cadence.
|
||||||
|
|
||||||
|
### G. Party combat multiplies the known per-turn DB chatter
|
||||||
|
|
||||||
|
Already tracked as `project_combat_session_cache_deferred`, now worse:
|
||||||
|
|
||||||
|
- `partyCombatantsForSession` (`combat_session_build.go:137`) rebuilds every seat
|
||||||
|
from scratch on every `!attack`/`!cast`/`!consume` (~6–8 SELECTs per seat).
|
||||||
|
A 3-member, 5-round fight ≈ 270–360 SELECTs.
|
||||||
|
- `rebuildRoster` (`combat_cmd.go:620`) then rebuilds *all* seats again after a
|
||||||
|
mid-fight buff, when only the casting seat changed.
|
||||||
|
- `runZoneCombatRoster` (`zone_combat_party.go:73`) loads char + equipment, then
|
||||||
|
`buildZoneCombatants` loads both again and discards the first pair — once per
|
||||||
|
seat per room, on the path that fights every room.
|
||||||
|
- `nudgeStalledPartyTurns` (`combat_party_turn.go:281`) does two session reads
|
||||||
|
plus a full N-seat rebuild per stalled fight, serially, on the 1-minute ticker,
|
||||||
|
while holding the fight lock.
|
||||||
|
- The enemy stat block is rebuilt once per seat and kept only for seat 0 (the
|
||||||
|
code comments admit it). CPU-only, but pure waste.
|
||||||
|
|
||||||
|
Cheapest real fix: memoize the built roster for the fight's lifetime. Mid-fight
|
||||||
|
buffs are already tracked as `ActorStatuses` deltas and re-applied by
|
||||||
|
`applySessionBuffs`, so stats are reproducible without re-reading the DB.
|
||||||
|
|
||||||
|
### ~~H. Guard probes re-query per message~~ — MEASURED, DECLINED 2026-07-10.
|
||||||
|
|
||||||
|
`isPartyMember` → `activeExpeditionFor` is up to 2 queries; `activeZoneRunFor`
|
||||||
|
adds more. A solo player with nothing in flight costs 3 queries to conclude "not
|
||||||
|
in a party". The original write-up said this was "repeated by each guard a
|
||||||
|
handler consults" and proposed resolving seat/expedition/run once per message
|
||||||
|
into `MessageContext` and threading it.
|
||||||
|
|
||||||
|
**The premise was overstated, and the fix is disproportionate. Not done.**
|
||||||
|
|
||||||
|
- **No per-message guard cascade exists.** `OnMessage` dispatch is strictly
|
||||||
|
one-handler-per-command (`if IsCommand(...) return handle(...)`). Nothing runs
|
||||||
|
these resolvers for *every* message; the redundancy is intra-handler only.
|
||||||
|
- **The one hot inbound path is already optimized.** `zoneCmdAdvance` /
|
||||||
|
`expeditionCmdRun` call `isPartyMember` directly *by design* — the code comment
|
||||||
|
spells out that this avoids re-resolving the run (which `advanceOnce` is about
|
||||||
|
to resolve) and double-firing the §4.3 idle reap. The authors already closed the
|
||||||
|
hot case.
|
||||||
|
- **The remaining 2–3× callers are cold.** A per-function scan found ≥2 resolver
|
||||||
|
calls only in `zoneCmdEnter`, `zoneCmdAbandon`, `expeditionCmdStart`, the status
|
||||||
|
impls (all one-shot commands), and `runAutopilotWalk` (5×, but it is the
|
||||||
|
*background* ticker path, not an inbound message). In those the calls are
|
||||||
|
typically a guard-check on one branch then a resolve on another — not the same
|
||||||
|
read twice.
|
||||||
|
- **The proposed fix has a real regression surface.** `MessageContext` is a
|
||||||
|
shared cross-plugin value type (movies, vibe, miniflux, …); the resolvers are
|
||||||
|
free functions used at 60+ sites. Threading a resolved snapshot means changing
|
||||||
|
the shared type and every call site, and carrying a memo that goes stale the
|
||||||
|
moment a handler writes the row mid-flight — the exact staleness-bug class items
|
||||||
|
A/M/N just fixed. All to save a few cheap indexed local SQLite reads on a
|
||||||
|
low-volume bot.
|
||||||
|
|
||||||
|
Cost/benefit is upside-down: high churn + a staleness regression surface for a
|
||||||
|
negligible perf gain on paths that are mostly already optimal. Left as-is
|
||||||
|
deliberately. Revisit only if a profiler ever shows these reads on a hot path.
|
||||||
|
|
||||||
|
### ~~I. `parseTemperIndex` is the third copy of the same menu parser~~ — Fixed 2026-07-10.
|
||||||
|
|
||||||
|
`adventure_temper.go` duplicated the "read leading digits, 1-indexed →
|
||||||
|
0-indexed, bounds-check" reply parser already inlined in `resolveMagicEquipReply`
|
||||||
|
(`magic_items_gameplay.go`) and `handleMasterworkEquipReply`
|
||||||
|
(`adventure_masterwork.go`). Promoted the temper copy to `parseMenuIndex(reply, n)`
|
||||||
|
and routed the other two through it.
|
||||||
|
|
||||||
|
Behaviour-preserving on all three: the old `parsed` flag was redundant with the
|
||||||
|
`idx < 0` guard (no leading digit leaves `idx=0`, then `idx--` → -1, rejected
|
||||||
|
either way), so no input changes verdict. The **retry disagreement the doc
|
||||||
|
flagged was deliberately left as-is** — this was a pure dedup, so magic-items and
|
||||||
|
temper still re-store the pending interaction on a bad parse and masterwork still
|
||||||
|
doesn't. Unifying that is a behaviour change and belongs in its own pass if
|
||||||
|
wanted. Full plugin suite green; the existing `parseMenuIndex` table test moved
|
||||||
|
with the rename.
|
||||||
|
|
||||||
|
### J. `!sell` fires the event anchor even when nothing sold (PLAUSIBLE)
|
||||||
|
|
||||||
|
`adventure.go:719` calls `maybeFireAnchoredEvent` unconditionally after
|
||||||
|
`handleSellCmd`, so `!sell nonexistentitem` can burn the player's one daily event
|
||||||
|
slot. Gating it needs `advSellItem`/`advSellAll` to return a `sold bool`.
|
||||||
|
|
||||||
|
**Probably leave it.** The anchor's stated design is presence-based — "a moment
|
||||||
|
the player is present and reading" — and a player who typed `!sell` and read the
|
||||||
|
reply *is* present. That matches the project's own rule that presence means any
|
||||||
|
chat, not a successful command. Flagged only so the choice is explicit.
|
||||||
|
|
||||||
|
### K. Confirm the A6 anchor set covers the player population — CONFIRMED REAL GAP 2026-07-10.
|
||||||
|
|
||||||
|
N1/A6 replaced the old "any daily-active player, any activity" mid-day event roll
|
||||||
|
with three presence anchors: the expedition Night digest, `!sell`, and arena
|
||||||
|
cashout. `tryTriggerEvent`'s `HasActedToday` gate was dropped with it.
|
||||||
|
|
||||||
|
**The code confirms the gap is real, not hypothetical.** The three, and only
|
||||||
|
three, `maybeFireAnchoredEvent` call sites are:
|
||||||
|
|
||||||
|
1. `expedition_autorun.go:312` — the end-of-day digest, and it is gated on
|
||||||
|
`campDecision.Night`. A Night camp only happens on a **multi-day** expedition;
|
||||||
|
a single-day dungeon/`!zone` run never reaches one.
|
||||||
|
2. `adventure.go:734` — after a `!sell` at Thom's.
|
||||||
|
3. `adventure_arena.go:633` — after an arena cashout.
|
||||||
|
|
||||||
|
So the excluded population is concrete: a player whose daily loop is
|
||||||
|
mining / foraging / fishing / babysit / single-day dungeon, who never sells at
|
||||||
|
Thom's, never enters the arena, and never runs a multi-day expedition, receives
|
||||||
|
**zero** mid-day events — the roll never even happens for them. That is exactly
|
||||||
|
the core daily-grind loop, so this is plausibly a large fraction of players, not
|
||||||
|
an edge case.
|
||||||
|
|
||||||
|
**Fourth anchor drafted 2026-07-10 (uncommitted, pending owner review).** The
|
||||||
|
on-theme, farm-resistant home is a foreground **single-day `!zone` clear** — the
|
||||||
|
climax DM the dungeon/harvest-via-walk player is demonstrably reading, and one
|
||||||
|
they cannot spam (a clear costs a full walk). Implemented as:
|
||||||
|
|
||||||
|
- `advEventChanceZoneClear = 0.08` in `adventure_events.go` (matches the digest;
|
||||||
|
it is the day's climax moment, and it is the single tuning knob).
|
||||||
|
- `advanceResult.zoneCleared` set true only in the *full*-clear branch of
|
||||||
|
`advanceOnceWithOpts` (not a mid-zone region clear, which continues the run and
|
||||||
|
would fire per region).
|
||||||
|
- Rolled in `zoneCmdAdvance` after the clear DM streams, via the unchanged
|
||||||
|
`maybeFireAnchoredEvent`. That is the foreground path only — autopilot
|
||||||
|
(`runAutopilotWalk`) and party members (refused earlier by `isPartyMember`)
|
||||||
|
never reach it. The per-day slot guard still caps everyone at one event/day.
|
||||||
|
|
||||||
|
Rates (see `TestZoneClearAnchorRate`): a zone-clear-only grind player at ~2
|
||||||
|
clears/day lands ~1.08 events/week — the same band as the fully-engaged
|
||||||
|
digest+sell+arena player (~1.19/week, unchanged). The rare all-four-in-one-day
|
||||||
|
player peaks at a ~1.65/week *probability* ceiling but still gets at most one
|
||||||
|
actual event/day.
|
||||||
|
|
||||||
|
**Two knobs remain the owner's call**, hence "pending review": the 0.08 chance,
|
||||||
|
and whether a single-day zone clear is even the right anchor for the grind loop
|
||||||
|
vs. `!resources` (rejected here as farmable — repeated `!resources` would just
|
||||||
|
re-roll the same daily slot) or leaving the grind loop event-free by design.
|
||||||
|
Full plugin suite green; no existing behaviour moved (the three prior anchors and
|
||||||
|
`TestAnchoredEventWeeklyRate` are untouched).
|
||||||
|
|
||||||
|
### ~~L. Dead helper: `openParty`~~ — Verified and removed 2026-07-10.
|
||||||
|
|
||||||
|
Confirmed dead: a repo-wide grep found `openParty` only in `expedition_party.go`
|
||||||
|
(the def) and `expedition_party_test.go`. The invite path goes `joinParty` →
|
||||||
|
`seatLeader`, which runs the same `INSERT ... role 'leader' ON CONFLICT DO
|
||||||
|
NOTHING` but reads the owner off the expedition row instead of trusting a
|
||||||
|
passed-in id. Deleted `openParty`; the eleven test call sites now use a
|
||||||
|
`seatLeaderFixture(t, expID)` helper that wraps `seatLeader` in a transaction
|
||||||
|
(the owner comes from the `seedExpedition` row, which always matched the id the
|
||||||
|
old calls passed). `seatLeader`'s doc comment absorbed the idempotency note that
|
||||||
|
justified `openParty`. Full plugin suite green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test gap worth closing — ~~the DM seam~~ CLOSED 2026-07-10
|
||||||
|
|
||||||
|
`SendDM` went straight through `Base` to a live client, with no fake/sink seam,
|
||||||
|
so the handler-level paths behind fixes #1, #2 and #5 could not be unit-tested.
|
||||||
|
That is why the party close-out bug survived a suite this thorough: every party
|
||||||
|
test is unit-level and stops below the DM boundary.
|
||||||
|
|
||||||
|
**Fixed 2026-07-10.** Added a `MessageSink` interface and a `Base.Sink` field
|
||||||
|
(`plugin.go`). When non-nil it captures every outbound message — both the DM
|
||||||
|
route (`SendDM`/`SendDMID`) and the room route (`SendMessage`/`SendMessageID`,
|
||||||
|
which is what the arena announcement actually uses) — and the live client is
|
||||||
|
never touched. Nil in production, so behaviour is unchanged; the whole
|
||||||
|
`internal/...` suite is green with the field added. One seam, no call-site churn:
|
||||||
|
the 765 `SendDM`/`replyDM`/`SendMessage` sites are all downstream of these four
|
||||||
|
methods.
|
||||||
|
|
||||||
|
New tests (`message_sink_test.go`): a `captureSink` test double + `installSink`
|
||||||
|
helper; `TestMessageSink_CapturesDMAndRoom` proving both routes divert with the
|
||||||
|
right target/text and that the `*ID` variants report back an id; and
|
||||||
|
`TestExpeditionCmdLeave_DMsBothEnds`, which drives the real fix-#2 handler end to
|
||||||
|
end and asserts both DMs land (member "turned back", leader notified) — a path
|
||||||
|
that was literally a silent no-op in a unit test before. `beginCombatTurn`'s
|
||||||
|
terminal path and the arena rollover announcement are now reachable the same way
|
||||||
|
(seam proven for both the DM and room routes); dedicated end-to-end tests for
|
||||||
|
those two remain easy follow-ups if wanted.
|
||||||
|
|
||||||
|
Added this pass: `TestPartyCasualtyLine_JoinsNamesLikeTheRestOfTheBot`,
|
||||||
|
and `TestPartySurvivors_SplitsOnHPNotOnOutcome` was rewritten as
|
||||||
|
`TestAnySurvivor_ReadsHPNotOutcome` when its helper was deleted.
|
||||||
|
|
||||||
|
Added in the second pass (`combat_armed_ability_test.go`): the close-out
|
||||||
|
bookkeeping is now reachable below the DM boundary via
|
||||||
|
`postCombatBookkeepingForSeat(sess, seat)`, which takes a plain `*CombatSession`
|
||||||
|
and needs no client — so item A's behaviour is directly unit-tested even though
|
||||||
|
`finishCombatSession` itself still isn't. (The `dmSink` seam that would have
|
||||||
|
covered `finishCombatSession` itself now exists — see above.)
|
||||||
|
|
||||||
|
Not covered by tests: item B's locking. `withExpeditionSupplies` is exercised
|
||||||
|
incidentally by the existing expedition suite, but nothing asserts the mutual
|
||||||
|
exclusion. A `t.Parallel` accept-vs-burn race test under `-race` would.
|
||||||
519
gogobee_combat_engine_plan.md
Normal file
519
gogobee_combat_engine_plan.md
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
# Combat engine — paying off the N-body debt
|
||||||
|
|
||||||
|
## Session 2026-07-11 (c) — §6 was never a balance problem
|
||||||
|
|
||||||
|
**IN FLIGHT: the verification sweep is still running on millenia** (`/tmp/s6_after.jsonl`,
|
||||||
|
10 classes × L10,L12 × 10 zones × n=25). Nothing below the diagnosis is measured yet.
|
||||||
|
|
||||||
|
### Committed
|
||||||
|
|
||||||
|
- `c9128fb` — §1's other half. Out-of-combat `!cast <heal> @friend`, scoped to the
|
||||||
|
people on your expedition. `--target` had been advertised by `!help` and swallowed
|
||||||
|
by the parser since SP2. Ally row is mutated with one guarded UPDATE in a
|
||||||
|
transaction (gifting's precedent), not a read-modify-write under a second lock:
|
||||||
|
two clerics healing each other would take their `advUserLock`s in opposite orders
|
||||||
|
and deadlock. Refunds the slot on every path that heals nobody.
|
||||||
|
- §6 auto-cast (`zone_combat_autocast.go`) — see below. Tested, unmeasured.
|
||||||
|
|
||||||
|
### The finding: cleric was not weak, it was not being played
|
||||||
|
|
||||||
|
Baseline re-run on HEAD (`sim_results/s6_baseline_l10l12.jsonl`, 5000 rows) confirmed
|
||||||
|
cleric still dead last: **46.4% vs fighter 81.4%**. §1's self-heal moved it ~0, as
|
||||||
|
predicted. But the *shape* of the failures is the whole story:
|
||||||
|
|
||||||
|
| class | cleared | **fled** | tpk |
|
||||||
|
|---|---|---|---|
|
||||||
|
| fighter | 407 | **0** | 93 |
|
||||||
|
| ranger | 395 | **0** | 105 |
|
||||||
|
| cleric | 232 | **167** | 101 |
|
||||||
|
|
||||||
|
**Cleric dies LESS than mage, sorcerer or warlock.** Its entire 35pp deficit is
|
||||||
|
*fleeing* — 167 of 500 runs end with the player alive and the expedition over.
|
||||||
|
Instrumenting the three `stopEnded` sites: **every one of those runs died at
|
||||||
|
`stopEnded via ROOM`** — an ordinary room fight. Not a patrol, not an elite, not the
|
||||||
|
boss.
|
||||||
|
|
||||||
|
**Root.** An ordinary room is `runZoneCombatRoster` → `SimulateCombat` on an 8-round
|
||||||
|
clock: one breath, **no turn engine, no action picker**. The only spell that could
|
||||||
|
ever land there was a `PendingCast` the player queued BY HAND with `!cast` before
|
||||||
|
walking in. On autopilot nobody queues one — so for every room of every expedition, a
|
||||||
|
caster fought with a weapon and nothing else. A cleric has no Extra Attack, a mace,
|
||||||
|
and its whole kit unusable. Measured on the room path at L10 (loss% by entry HP):
|
||||||
|
|
||||||
|
| entry HP | fighter | druid | bard | mage | sorcerer | **cleric** |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 100% | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% |
|
||||||
|
| 70% | 0.0% | 0.0% | 0.0% | 0.0% | 1.0% | **1.5%** |
|
||||||
|
| 35% | 0.0% | 0.0% | 0.5% | 0.5% | 10.0% | **8.0%** |
|
||||||
|
|
||||||
|
Small per room — and a run is ~16 rooms, and **one loss ends the expedition**
|
||||||
|
("the fight drags on, X outlasts you, you retreat"). It compounds into 33% of runs.
|
||||||
|
|
||||||
|
**The tell.** `dnd_class_balance.go:431` — the harness the class corpus was tuned on
|
||||||
|
— *always* hands a caster their best damage spell (`pickBestDamageSpell` +
|
||||||
|
`applyHarnessSpellCast`) before it simulates. So the numbers that say "cleric is a
|
||||||
|
weak class" modelled a cleric who casts; the live room modelled one who does not.
|
||||||
|
The corpus and the game disagreed about what a cleric is.
|
||||||
|
|
||||||
|
This is the same defect as everything else in this plan: **the action model is
|
||||||
|
narrower than the kit.** §1 (the picker never healed), §3 (the companion never
|
||||||
|
acted), Pete (`LoadDnDCharacter` → nil → `"attack"`), and now every caster in every
|
||||||
|
room. It is not a tuning dial and it must not be fixed with one.
|
||||||
|
|
||||||
|
### The change (unmeasured)
|
||||||
|
|
||||||
|
`autoCastForAutoResolve` — no queued spell + a real slot in hand → cast the best
|
||||||
|
damage spell, spend the slot, fold it in. Additive pre-damage, exactly as a
|
||||||
|
hand-queued `PendingCast` has always been, so it stays comparable to the corpus
|
||||||
|
rather than being a fresh mechanic.
|
||||||
|
|
||||||
|
- **Slot-aware, not free.** `pickBestDamageSpell` reads `slotsForClassLevel` — the
|
||||||
|
*theoretical* class table. Reusing it here would be an infinite spell: the same
|
||||||
|
"no row to persist onto, so it arrives fresh" bug that gave the companion an
|
||||||
|
unlimited body ([[project_companion_free_lunches]]). The new picker reads the
|
||||||
|
seat's actual remaining slots and `consumeSpellSlot`s.
|
||||||
|
- **Never upcasts.** The big slots are what the elite and the boss are for, and the
|
||||||
|
turn engine spends them there. A picker that nukes a goblin with a 5th-level slot
|
||||||
|
leaves the caster swinging a stick at the thing that matters.
|
||||||
|
- Cleric owns no damage spell at slot 2 or 4, so those stay unspendable in rooms —
|
||||||
|
correct, and pinned by a test.
|
||||||
|
- Both goldens byte-identical (they pin the engine; this changes its *inputs* at the
|
||||||
|
zone layer). Martials provably untouched.
|
||||||
|
|
||||||
|
### When the sweep lands
|
||||||
|
|
||||||
|
Read cleric's **fled** count, not just its clear rate — fled going to ~0 is the
|
||||||
|
thing this predicts. Expect all 6 casters to move; that is the deliberate
|
||||||
|
re-baseline. **Watch for overshoot on bard/druid** (already 67–72%): they get the
|
||||||
|
same buff and were not the problem. If they overshoot, the lever is this picker, not
|
||||||
|
monster scaling ([[project_difficulty_target]]).
|
||||||
|
|
||||||
|
|
||||||
|
## Session 2026-07-11 (b) — the companion can cast, and three free lunches fell out
|
||||||
|
|
||||||
|
**Committed: `01c2cb2` (§1 — the seat-scoped spellbook).** Everything below it is
|
||||||
|
working-tree.
|
||||||
|
|
||||||
|
The revisit-Pete step ("Pete cannot cast") turned out to be four separate defects
|
||||||
|
stacked on each other. The unit tests were green for all four; only the sweep with
|
||||||
|
a **control arm** ever told the truth. Read [[feedback_unit_tests_miss_engine_bugs]]
|
||||||
|
and then read it again.
|
||||||
|
|
||||||
|
### The one that was asked for
|
||||||
|
|
||||||
|
Every spell lookup is keyed on a user id and answered by a `dnd_*` table. Pete has
|
||||||
|
rows in none of them, by design. So `pickAutoCombatActionForSeat`'s first line —
|
||||||
|
`LoadDnDCharacter(uid)` → nil → `return "attack"` — meant **a hired Cleric swung a
|
||||||
|
mace, every turn, forever**. Role-fill hands a lone martial a Cleric, so that was
|
||||||
|
the *common* case. Fixed with a seat-scoped spellbook (`combat_seat_spells.go`):
|
||||||
|
humans delegate to the DB verbatim (both goldens byte-identical), the companion is
|
||||||
|
answered from his synthetic sheet.
|
||||||
|
|
||||||
|
### The three that were not
|
||||||
|
|
||||||
|
Each of these was found by measuring, and each is the same mistake: *"he has no row
|
||||||
|
to persist onto, so he arrives fresh."*
|
||||||
|
|
||||||
|
1. **His spell slots refilled every fight.** I parked the ledger on his combat
|
||||||
|
*seat* — and a seat is per-session. A human rations one pool across a 30-room
|
||||||
|
run and gets it back at camp; rationing it **is** the caster's game. Moved to
|
||||||
|
`expedition_party.companion_slots_used`, refreshed at camp.
|
||||||
|
*(Worth ~0pp on its own — an expedition only holds ~2 real fights, so the pool
|
||||||
|
never binds. Correct, but not the lever. I predicted this one would be the whole
|
||||||
|
answer and I was wrong.)*
|
||||||
|
2. **His body refilled every fight.** `combat_party_start.go` seated him at
|
||||||
|
`player.Stats.MaxHP` and the close-out skipped him with the comment *"he arrives
|
||||||
|
fresh next time"*. That is an infinite body: he soaked a share of every fight's
|
||||||
|
incoming and then reset, while the humans beside him bled all the way to camp.
|
||||||
|
**This was the carry.** Moved to `expedition_party.companion_hp`; healed at camp.
|
||||||
|
3. **No autopiloted caster has ever healed itself.** `simPickAllyHeal` skipped
|
||||||
|
`i == seat` and bailed on `!IsParty()`. Between them: a solo cleric carries
|
||||||
|
`cure_wounds` for a whole run and dies with a full pool of level-1 slots. Now
|
||||||
|
`simPickHeal`: heal whoever is worst off, which is sometimes you.
|
||||||
|
**It is NOT the answer to §6, and I guessed that it would be.** It moved solo
|
||||||
|
66.1% → 66.2%: the picker reaches for a healing *consumable* first and the sim
|
||||||
|
stocks them, so a human almost never falls through to the spell. The corpus is
|
||||||
|
therefore undisturbed and no re-baseline is owed. Pete carries no consumables,
|
||||||
|
so it is his only heal — which is the reason to keep it.
|
||||||
|
|
||||||
|
### What the arms actually said
|
||||||
|
|
||||||
|
640 runs/arm, 10 classes × L10,L12 × 4 zones. "Like-for-like" = the leaders whose
|
||||||
|
role-fill gives Pete a **Cleric**, compared against a human **Cleric** follower.
|
||||||
|
|
||||||
|
| arm | like-for-like clear | vs solo |
|
||||||
|
|---|---|---|
|
||||||
|
| solo | 68.2% | — |
|
||||||
|
| + a human cleric follower (leader's level, geared) | 79.7% | +11.5pp |
|
||||||
|
| + Pete, HEAD (free heal, cannot cast) | 77.1% | +8.9pp |
|
||||||
|
| + Pete, casting + free heal | **94.8%** | +26.6pp — **carry** |
|
||||||
|
| + Pete, casting + carries his wounds | **69.5%** | +1.3pp — **useless** |
|
||||||
|
|
||||||
|
**The reference arm is the whole point.** Measured against *solo*, even mace-only
|
||||||
|
Pete looked like a carry (+8.9pp) — but parties are *designed* to be safer, so solo
|
||||||
|
is the wrong yardstick. Measured against **a human follower**, the real finding
|
||||||
|
appears: a gearless, level-penalized hireling was out-clearing a fully-geared human
|
||||||
|
cleric *of the leader's own level* by 15pp. A hireling must never beat a player.
|
||||||
|
His party fled 5 runs out of 640; the human party fled 56.
|
||||||
|
|
||||||
|
### §2(a) SHIPPED — and it landed in band
|
||||||
|
|
||||||
|
`055f07d`. Seats carry a **SeatWeight**; both levers (boss HP, enemy action economy)
|
||||||
|
scale on the summed weight of the **living** seats instead of a head count. The
|
||||||
|
weight is **level-based** — priced against the leader, times a hireling discount
|
||||||
|
(`companionSeatWeight = 0.65`, the one tunable).
|
||||||
|
|
||||||
|
**Not** a power score: HP×damage would rank a cleric below a fighter and quietly
|
||||||
|
make every mixed *human* party easier — a difficulty regression smuggled in under a
|
||||||
|
bug fix.
|
||||||
|
|
||||||
|
The safety argument is one property: **a peer weighs exactly 1.0.** The curves
|
||||||
|
interpolate between P8's tuned integer knots — (1, 1.0), (2, 2.4), 2n−1 from 3 up —
|
||||||
|
so every integer input returns exactly what it always returned. Solo byte-identical,
|
||||||
|
a party of same-level humans byte-identical, both goldens unmoved. Only an *unequal*
|
||||||
|
roster lands between knots, which is the whole point. It also finishes §2(b): a
|
||||||
|
downed seat now buys the enemy nothing (§2(b) fixed only the head-count half — a
|
||||||
|
corpse still carried its full weight).
|
||||||
|
|
||||||
|
| | solo | + Pete | + human cleric peer |
|
||||||
|
|---|---|---|---|
|
||||||
|
| like-for-like clear | 69.0% | **76.8%** (+7.8pp) | 77.6% (+8.6pp) |
|
||||||
|
|
||||||
|
| band | solo | + Pete | lift |
|
||||||
|
|---|---|---|---|
|
||||||
|
| trailing (solo <40%) | 10.0% | 31.0% | **+21.0pp** |
|
||||||
|
| middle | 58.9% | 76.8% | +17.9pp |
|
||||||
|
| leading (solo ≥70%) | 93.5% | 99.2% | **+5.7pp** |
|
||||||
|
|
||||||
|
Help, never a carry — and he stays below a real human of the leader's level, which
|
||||||
|
is the invariant a hireling must never break.
|
||||||
|
|
||||||
|
### How it got there (the record of being wrong)
|
||||||
|
|
||||||
|
With all three free lunches gone, the same grid says (like-for-like subset):
|
||||||
|
|
||||||
|
| | solo | + human cleric peer | + Pete |
|
||||||
|
|---|---|---|---|
|
||||||
|
| clear | 69.0% | 77.6% (+8.6pp) | **66.1% (−2.9pp)** |
|
||||||
|
|
||||||
|
**Hiring him is now worse than going alone** — and that is §2, unmasked. The free
|
||||||
|
full-heal had been paying his enemy-scaling bill for him. A below-median seat costs
|
||||||
|
the boss +15% HP and lifts the enemy's action economy from 1 to 2.4 attacks a round,
|
||||||
|
and he does not give that much back.
|
||||||
|
|
||||||
|
I talked myself out of §2(a) mid-session, on the grounds that discounting a weak
|
||||||
|
seat would enlarge the carry. That was right *about the buggy build* and wrong about
|
||||||
|
the engine: with the infinite body removed, the plan's original diagnosis is exactly
|
||||||
|
correct and the sweep now argues **for** §2(a).
|
||||||
|
|
||||||
|
I talked myself **out** of §2(a) mid-session, on the grounds that discounting a weak
|
||||||
|
seat would enlarge the carry. That was right about the *buggy build* and wrong about
|
||||||
|
the engine: the infinite body had been paying the seat's scaling bill for it. With
|
||||||
|
the free lunches gone, the plan's original diagnosis was exactly correct. **The
|
||||||
|
order mattered** — §2(a) done first, on top of the free full-heal, would have made
|
||||||
|
things worse and looked like it was working.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
- A dropped companion returns at 1 HP (`companionSeatHP`'s floor) rather than being
|
||||||
|
benched until camp. Deliberate: there is no companion-death rule and inventing one
|
||||||
|
inside a bug fix would have been a second feature.
|
||||||
|
- **§6 is still open, and self-heal was NOT it.** Solo cleric is 28–34% against
|
||||||
|
fighter's 100% on this grid. Re-read it off the current corpus before tuning.
|
||||||
|
- `companionSeatWeight = 0.65` is the single tunable and was not swept — it landed
|
||||||
|
in band on the first value tried. If Pete ever needs to be weaker, raise it.
|
||||||
|
- Out-of-combat `!cast --target` (`dnd_cast.go`) is still self-only.
|
||||||
|
- **Deployable now.** Hiring him is a real, bounded help at every level.
|
||||||
|
|
||||||
|
|
||||||
|
> The party engine (N3) widened the *roster* from 1 to N but never widened the
|
||||||
|
> *action model*, the *scaling model*, or the *test net* to match. Everything
|
||||||
|
> below is a symptom of that one gap. Working-tree doc; do not commit
|
||||||
|
> (feedback_dont_commit_plan_mds).
|
||||||
|
|
||||||
|
## Status @ 2026-07-11 — §1–§5 SHIPPED (uncommitted, unmeasured)
|
||||||
|
|
||||||
|
All five sections are implemented and the suite is green. Nothing is committed.
|
||||||
|
|
||||||
|
| § | What | State |
|
||||||
|
|---|------|-------|
|
||||||
|
| §5 | Party golden + seat attribution | ✅ `party_characterization.golden` (1938 lines, 9 scenarios × 5 seeds) + `TestPartyCharacterization_OneSeatIsStillSolo`; sim trace gets `simTraceEvent` (seat always emitted). Wire format left alone — `TestP5Fields_StayOffSoloRows` was right and I was wrong. |
|
||||||
|
| §3 | Engine-driven seats | ✅ `ActorStatuses.EngineDriven`, permanent, no command clears it. `beginCombatTurn` now REFUSES a command from an engine seat. `autoDriveCombat` calls `driveEngineSeat` instead of impersonating the seat. |
|
||||||
|
| §2(b) | Living-seat action economy | ✅ `enemyActionsThisRound` counts `livingActors(st)`, not `len(st.actors)`. **Party golden moved deliberately** (survivor ends 59 HP, was 51). Solo golden byte-identical. |
|
||||||
|
| §4 | One roster accessor | ✅ `expeditionParty()` / `partyHumans()` / `PartySeat{Kind}`. Cannot return an empty party for a solo run — that is the level-1-companion bug, pinned by `TestParty_SoloExpeditionStillHasAParty`. |
|
||||||
|
| §1 | Ally-targeted actions | ✅ `turnActionEffect.AllyHeal/AllySeat`; `!cast cure wounds @alex` **and** `--target @alex` (the flag `!help` advertised and `parseCombatCast` silently swallowed since SP2). `simPickAllyHeal` so autopiloted/engine healers use it. Will not raise the dead. |
|
||||||
|
|
||||||
|
**Both goldens hold. Solo is byte-identical throughout — the balance corpus is
|
||||||
|
intact.**
|
||||||
|
|
||||||
|
### Post-fix sweep (2026-07-11, n=750/arm on millenia)
|
||||||
|
|
||||||
|
| | clear |
|
||||||
|
|---|---|
|
||||||
|
| solo | 48.5% |
|
||||||
|
| solo + hired Pete | **63.9%** (+15.3pp) |
|
||||||
|
| solo *(the 2-human cells)* | 65.7% |
|
||||||
|
| \+ a human cleric follower | **87.7%** (+22.0pp) |
|
||||||
|
|
||||||
|
Pete went from **−28pp (worse than nobody) to +15.3pp**, and the lift lands exactly
|
||||||
|
where the plan asked for it:
|
||||||
|
|
||||||
|
| band | n | mean lift |
|
||||||
|
|---|---|---|
|
||||||
|
| trailing cells (solo <40%) | 15 | **+28.0pp** |
|
||||||
|
| middle (40–70%) | 3 | +5.3pp |
|
||||||
|
| leading cells (solo ≥70%) | 12 | **+2.0pp** |
|
||||||
|
|
||||||
|
That is "help, never a carry", measured rather than asserted: he rescues the
|
||||||
|
players who were drowning and barely moves the ones who were already fine. §2(a)
|
||||||
|
may now be unnecessary — the §2(b) fix appears to have been most of it.
|
||||||
|
|
||||||
|
**⚠️ READ THE NOISE FLOOR BEFORE TRUSTING ANY CELL.** The sim is not seeded per
|
||||||
|
cell. The same binary, same cell (bard/L10/feywild, n=25), three times:
|
||||||
|
**36% / 56% / 56%** — a 20pp spread from RNG alone.
|
||||||
|
|
||||||
|
- **Per-cell numbers at n=25 are noise.** Do not tune against them.
|
||||||
|
- Only the **n=750 aggregates** carry signal. The solo arm's 51.9% → 48.5% drift
|
||||||
|
across engine versions is inside that noise band (and the solo golden is
|
||||||
|
byte-identical, so solo combat provably did not change).
|
||||||
|
- Anything huge (the old fighter/L10/feywild 100% → 4%) is real; anything under
|
||||||
|
~20pp on a single cell is not.
|
||||||
|
- **Next sweep: raise `-runs` to 100+ per cell** if per-cell reads are needed.
|
||||||
|
|
||||||
|
### What is NOT done
|
||||||
|
|
||||||
|
- **Measurement.** The engine moved under every number in this doc. A full
|
||||||
|
re-sweep (solo / solo+Pete / 2-human-with-a-cleric) is in flight; every figure
|
||||||
|
quoted below predates §1–§5 and is now **stale**.
|
||||||
|
- **§2(a)** — power-based enemy scaling. §2(b) only stopped corpses from buffing
|
||||||
|
the boss. A *weak* living seat still costs a full seat's worth of enemy scaling,
|
||||||
|
which is why hiring Pete could still hurt a strong leader.
|
||||||
|
- **§6** — solo cleric bump. Blocked on the re-baseline, deliberately.
|
||||||
|
- **Pete cannot cast.** `castActionForSeat` loads a sheet from the DB and he has
|
||||||
|
none by design, so a hired "Cleric" *still* cannot heal. §1 fixed human clerics;
|
||||||
|
the companion needs a synthetic spell pool (or to be restricted to martial
|
||||||
|
chassis). **This is the revisit-Pete step.**
|
||||||
|
- Out-of-combat `!cast --target` (`dnd_cast.go`) is still self-only. Combat is
|
||||||
|
where it mattered; that one can follow.
|
||||||
|
|
||||||
|
## Why this plan exists
|
||||||
|
|
||||||
|
The Pete companion (`!expedition hire`) was built in one session and worked on
|
||||||
|
every unit test while being, in the sweep, **worse than no companion at all**
|
||||||
|
(solo 65% clear → solo+Pete 33%). Chasing that found four defects. Only one of
|
||||||
|
them was Pete's. The rest were sitting in the engine, in prod, since N3:
|
||||||
|
|
||||||
|
| Symptom found via the companion | Who else it affects | Root |
|
||||||
|
|---|---|---|
|
||||||
|
| A hired Cleric can't heal anyone | **Every human cleric in every party** | §1 |
|
||||||
|
| A weak seat makes the party *worse* | Any under-levelled friend; every downed seat | §2 |
|
||||||
|
| The companion lost his turn after round 1 | Any engine-driven seat, incl. away-player autopilot | §3 |
|
||||||
|
| The companion was silently level 1 | Anything reading "the party" of a solo run | §4 |
|
||||||
|
| Nobody noticed any of it | — | §5 |
|
||||||
|
|
||||||
|
§5 is the one that matters most: **there is no party golden.** The
|
||||||
|
characterization golden (`combat_characterization.golden`, 7468 lines) runs
|
||||||
|
`simulateCombatWithRNG(player, enemy)` — one player, one enemy, every scenario.
|
||||||
|
Party combat has *no* pinned behaviour, so N3 shipped a healer class that cannot
|
||||||
|
heal and the corpus said nothing. Whatever else we do, that gets fixed.
|
||||||
|
|
||||||
|
**Sequencing:** §5 first (build the net), then §1–§4 (change the engine while the
|
||||||
|
net is under us). Doing it the other way round means changing the most heavily
|
||||||
|
tuned code in the repo with no way to see what moved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 — Actions cannot target another seat
|
||||||
|
|
||||||
|
**The bug.** Every heal in the engine is self-scoped. `MistyHealProc`/
|
||||||
|
`MistyHealAmt` heal the actor. `HealItem`/`HealItemCharges` fire the actor's own
|
||||||
|
sub-50% trigger. `grep` for an ally-targeted action returns nothing; the only
|
||||||
|
seat-targeting that exists anywhere is `enemyTargetSeat` — which seat the
|
||||||
|
*monster* swings at. A party cleric is a cleric in chassis and passives only:
|
||||||
|
**they cannot put one hit point back on a friend.**
|
||||||
|
|
||||||
|
**Root.** `PlayerAction{Kind, Effect}` (`combat_turn_engine.go:51`) has no target
|
||||||
|
field. It was written when "the player" was unambiguous, and N3 never revisited
|
||||||
|
it — the roster got wider, the action stayed pointed at the same two things (me,
|
||||||
|
and the enemy).
|
||||||
|
|
||||||
|
**Change.**
|
||||||
|
- `PlayerAction` gains a target: `TargetSeat int` (−1 = the enemy, the default,
|
||||||
|
which keeps every existing call site meaning exactly what it means today).
|
||||||
|
- `turnActionEffect` grows an ally-heal payload; `runPartyCombatRound` applies HP
|
||||||
|
deltas to `TargetSeat` rather than to the actor.
|
||||||
|
- `!cast <spell> @user` parses a target; solo ignores it.
|
||||||
|
- Cleric/Druid/Bard/Paladin get their heal spells actually wired to it.
|
||||||
|
- `pickAutoCombatActionForSeat` gains a heal-the-lowest-living-seat rule, so
|
||||||
|
autopiloted and companion healers behave like a competent player.
|
||||||
|
|
||||||
|
**This moves the golden.** It adds an action the picker can choose. Deliberate
|
||||||
|
re-baseline, after §5.
|
||||||
|
|
||||||
|
**Risk.** Touching `runPartyCombatRound` is touching the most tuned code we own.
|
||||||
|
Mitigated by §5's party golden plus the existing solo golden staying byte-identical
|
||||||
|
(no solo fight has a second seat to target).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §2 — Enemy scaling counts seats, not strength
|
||||||
|
|
||||||
|
**The bug.** P8 scales the enemy's action economy to the roster, and
|
||||||
|
`partyEnemyHPScale` gives +15% HP for any roster ≥ 2. Both count **seats**. So a
|
||||||
|
seat contributes its full cost to the enemy the moment it sits down, regardless
|
||||||
|
of what it actually brings — and keeps contributing that cost **after it dies**.
|
||||||
|
|
||||||
|
Measured, in the same cell (fighter L10, feywild):
|
||||||
|
|
||||||
|
| | clear |
|
||||||
|
|---|---|
|
||||||
|
| solo | 100% |
|
||||||
|
| \+ Pete as Cleric (a chassis he can't play) | 32% |
|
||||||
|
| \+ Pete as Fighter (his best case) | 68% |
|
||||||
|
| \+ a second *human* | 100% |
|
||||||
|
|
||||||
|
A deliberately below-median body is a **net negative at every level** — the boss
|
||||||
|
gains more than the seat gives back. That is not a Pete property. It is true of
|
||||||
|
any under-levelled friend you invite, and of every seat that goes down early
|
||||||
|
(the corpse keeps inflating the boss for the survivors).
|
||||||
|
|
||||||
|
**Root.** The scaling model assumes every seat is a median player. Nothing
|
||||||
|
enforces that, and two shipped features (parties, then companions) violate it.
|
||||||
|
|
||||||
|
**Change — pick one, and it is a design call, not a mechanical one:**
|
||||||
|
- **(a) Scale on contributed power, not seat count.** Enemy HP/actions scale on
|
||||||
|
the roster's summed effective level relative to the leader's. Correct, and it
|
||||||
|
fixes the under-levelled-friend case too. Most work; needs its own sweep.
|
||||||
|
- **(b) Scale on *living* seats, re-derived per round.** Cheap, and kills the
|
||||||
|
dead-seat-still-inflates-the-boss bug on its own. Does not fix the
|
||||||
|
weak-seat case.
|
||||||
|
- **(c) Don't scale for engine-driven seats at all.** Cheapest; makes hiring
|
||||||
|
strictly positive. Risks "carry", and leaves the human-party half untouched.
|
||||||
|
|
||||||
|
Recommendation: **(b) now** (it is a straight bug — a dead man should not be
|
||||||
|
buffing the boss), then **(a)** as the real fix once §5 can see it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §3 — Engine-driven seats are a status flag, not a concept
|
||||||
|
|
||||||
|
**The bug.** A seat with nobody at the keyboard is modelled as
|
||||||
|
`ActorStatuses.Autopilot = true`. But `beginCombatTurn` clears that flag on any
|
||||||
|
command arriving from that seat — *"They typed, so they are here."* And
|
||||||
|
`autoDriveCombat` drives a party by **dispatching each seat's turn as a fake chat
|
||||||
|
command from that seat's Matrix ID**. So the autopilot driver's own move looked
|
||||||
|
like the player coming back, cleared the latch, and the seat went inert for the
|
||||||
|
rest of the fight.
|
||||||
|
|
||||||
|
That is what made the companion stand in fights doing nothing. The same shape is
|
||||||
|
live for away-player autopilot today, and it is only luck that a human eventually
|
||||||
|
types something and re-latches.
|
||||||
|
|
||||||
|
**Root.** There is no first-class notion of "this combatant is driven by the
|
||||||
|
engine". Ownership of a turn is inferred from a Matrix message, which is a
|
||||||
|
transport detail leaking into the combat engine.
|
||||||
|
|
||||||
|
**Change.**
|
||||||
|
- A seat is `DrivenBy: human | away | engine`, persisted, not a bool that any
|
||||||
|
command can clear. `engine` is permanent; `away` is what a keystroke clears.
|
||||||
|
- `autoDriveCombat` stops impersonating seats. It calls the seat driver directly
|
||||||
|
(`driveCompanionSeat` is the shape; generalize it to any non-human seat).
|
||||||
|
- Command handlers refuse any non-human seat outright, rather than half-working.
|
||||||
|
|
||||||
|
**Bonus.** This is the prerequisite for the deferred Phase-16 "Pete runs his own
|
||||||
|
expeditions", and for any future NPC ally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §4 — "the party" and "the roster" disagree
|
||||||
|
|
||||||
|
**The bug.** A solo expedition has **no `expedition_party` rows** (absence means
|
||||||
|
solo; the roster materializes on the first invite). So any code that answers
|
||||||
|
"who is in this party?" by reading the roster gets **nobody** for a solo player
|
||||||
|
— and a plausible-looking fallback silently takes over. That is exactly how the
|
||||||
|
companion got hired at **level 1** for every solo player: the one case the
|
||||||
|
feature exists for.
|
||||||
|
|
||||||
|
**Root.** Two representations of the same fact ("who is on this expedition") that
|
||||||
|
disagree in the solo case, with no single accessor. `partySize()` and
|
||||||
|
`partyMembers()` and `expeditionAudience()` and `expeditionSeats()` and
|
||||||
|
`fightRoster()` all answer slightly different questions and are easy to reach for
|
||||||
|
by the wrong name — I reached for the wrong one twice while building the
|
||||||
|
companion, once fixed by a test and once only by a 1500-run sweep.
|
||||||
|
|
||||||
|
**Change.** One accessor that always includes the owner —
|
||||||
|
`expeditionParty(expeditionID) []Member` with `Member.Kind = leader|member|companion`
|
||||||
|
— and every consumer derives its own view from it (mail excludes companions,
|
||||||
|
seats include them, supply burn counts mouths). Delete the ad-hoc set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5 — There is no party golden (do this first)
|
||||||
|
|
||||||
|
**The bug.** `TestCombatCharacterization` pins *solo* combat, exhaustively (57
|
||||||
|
scenarios × seeds). Party combat has nothing. The entire N3 engine — initiative,
|
||||||
|
seat order, enemy targeting, action economy, close-out — is unpinned. That is why
|
||||||
|
a healer class that cannot heal shipped without a single test going red, and why
|
||||||
|
the companion's collapse was invisible until a 1500-run sweep.
|
||||||
|
|
||||||
|
**Change.**
|
||||||
|
- `TestPartyCharacterization` + `party_characterization.golden`: N-seat rosters
|
||||||
|
(2 and 3), mixed classes, downed seats, an engine-driven seat, seeded and
|
||||||
|
byte-pinned exactly like the solo one.
|
||||||
|
- A **balance regression sweep** in CI-able form: the `expedition-sim` matrix at
|
||||||
|
low n, asserting clear-rates stay inside a band. The sweep is what caught all
|
||||||
|
of this; it should not have to be run by hand.
|
||||||
|
- Fix event seat attribution: `CombatEvent.Seat` is `omitempty`, so seat 0 and
|
||||||
|
"no seat" are indistinguishable in a trace — which cost real time during this
|
||||||
|
investigation, sending me after a phantom "Pete never swings" bug that was
|
||||||
|
partly a reporting artifact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §6 — Clerics are weak in solo (raised 2026-07-11)
|
||||||
|
|
||||||
|
Separate from the party gap, and real: the class corpus has cleric in the 46–56%
|
||||||
|
band against fighter's ~82% ([[project_d8prereq_baseline]]).
|
||||||
|
|
||||||
|
**Do not tune this yet, and specifically do not tune it in isolation.** Two
|
||||||
|
reasons:
|
||||||
|
|
||||||
|
1. §1 *is* the cleric fix in party play — an ally-targeted heal is the class's
|
||||||
|
entire identity, and today they cannot use it on anybody. Whatever solo gap
|
||||||
|
remains should be measured **after** that lands, or we buff them twice.
|
||||||
|
2. `d8prereq_corpus` predates parties entirely and the party engine has moved
|
||||||
|
twice today (§2b, and §1 next). It is a stale baseline. **Re-baseline first**,
|
||||||
|
then read the cleric gap off the new corpus.
|
||||||
|
|
||||||
|
Then, if solo cleric is still short: lift the trailer, per the standing rule
|
||||||
|
([[project_difficulty_target]]) — do not nerf the martial leaders and do not touch
|
||||||
|
monster scaling.
|
||||||
|
|
||||||
|
## Ordering
|
||||||
|
|
||||||
|
1. **§5** — build the net. Party golden + seat attribution. No behaviour change.
|
||||||
|
2. **§3** — engine-driven seats. Contained, no balance impact, unblocks the rest.
|
||||||
|
3. **§2(b)** — living-seat scaling. Straight bug fix; party golden will move once,
|
||||||
|
deliberately.
|
||||||
|
4. **§4** — unify the roster accessor. Mechanical, guarded by §5.
|
||||||
|
5. **§1** — ally-targeted actions + heals. The big one. Moves both goldens;
|
||||||
|
re-baseline the class corpus after ([[project_d8prereq_baseline]] predates
|
||||||
|
parties entirely and is due anyway).
|
||||||
|
6. **§2(a)** — power-based scaling, then re-sweep the companion and re-decide his
|
||||||
|
below-median premise. It may not need to survive: once the boss stops
|
||||||
|
overcharging for him, "below median" may be exactly right.
|
||||||
|
|
||||||
|
## Meanwhile: the companion
|
||||||
|
|
||||||
|
He is **fixed and net-positive where he is meant to be** (+15.7pp in trailing
|
||||||
|
cells, ~neutral overall), but he still regresses strong solo leaders (§2) and
|
||||||
|
role-fill still hands martials a Cleric who cannot heal (§1). Two options until
|
||||||
|
this plan lands:
|
||||||
|
|
||||||
|
- **Ship him martial-only** (he plays chassis he can actually use — measured
|
||||||
|
32% → 68%), and let §1 restore the healer fill later. Honest, cheap, no engine
|
||||||
|
change.
|
||||||
|
- **Hold him** until §1/§2 land, and announce the rest of Adventure without him.
|
||||||
|
|
||||||
|
Do NOT bandaid by hand-tuning his stats until he "looks right" — his numbers are
|
||||||
|
not the problem, and tuning them would bake the engine's bugs into his stat block.
|
||||||
1547
gogobee_engagement_plan.md
Normal file
1547
gogobee_engagement_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
356
gogobee_mischief_plan.md
Normal file
356
gogobee_mischief_plan.md
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# Mischief Makers — paid monster hits on live expeditions
|
||||||
|
|
||||||
|
Working-tree plan doc. Do not commit.
|
||||||
|
|
||||||
|
## The pitch (restated)
|
||||||
|
|
||||||
|
Parodia members spend euros to send a monster — grunt / mob / elite / boss — at a
|
||||||
|
player who is out on an expedition. Orders come from Pete's Adventure web page
|
||||||
|
(and from Matrix). The games room hears that a contract is out and gets a window
|
||||||
|
to help the target… or "help" them. Surviving an attempt pays the target.
|
||||||
|
|
||||||
|
## What the codebase already gives us (verified 2026-07-13)
|
||||||
|
|
||||||
|
| Need | Existing machinery |
|
||||||
|
|---|---|
|
||||||
|
| Currency + escrow | `EuroPlugin.Debit/Credit` (`euro.go:364,:395`), duel escrow pattern (`adventure_duel.go:422,:529,:667`), community pot (`adventure_rival.go:193-241`) |
|
||||||
|
| "Is X out right now?" | `getActiveExpedition` (`dnd_expedition.go:215`), roster already pushed to Pete every 2 min |
|
||||||
|
| Mid-run combat injection | `runHarvestInterrupt` (`dnd_expedition_combat.go:121`) — picks enemy, surprise nick, `runZoneCombatRoster`, full win/retreat/death close-out. **This is the template.** |
|
||||||
|
| Safe-delivery gating | ambient ticker's CAS claim + `hasActiveCombatSession` + quiet-window checks (`expedition_ambient.go:97,:109,:156`) |
|
||||||
|
| Grunt→boss ladder | Arena tiers 1–5 (`adventure_arena_monsters.go`), `arenaMonsterToTemplate` (`:251`); zone pools tag `IsElite` |
|
||||||
|
| Games-room announce | `gamesRoom()` + world-boss announce helpers (`adventure_worldboss.go:528-566`) |
|
||||||
|
| Respond-within-window | duel challenge window + atomic single-winner claim (`adventure_duel.go:43,:85`) |
|
||||||
|
| Survival payout kit | `euro.Credit`, `rollAdvTreasureDrop`, `consumableCache`, renown, world-boss payout bundle (`adventure_worldboss.go:466-493`) |
|
||||||
|
| Pete pipe | durable fact queue, bearer auth, event-type taxonomy; **strictly one-way gogobee→Pete today** |
|
||||||
|
|
||||||
|
Hard constraint (Pete `internal/web/roster.go:23-25`): *"Pete has no route back
|
||||||
|
into the game box's network and we are not opening one."* We honor that: the web
|
||||||
|
storefront stores orders in Pete's DB and **gogobee polls Pete** (gogobee stays
|
||||||
|
the only initiator, same tailnet + bearer pattern as ingest, just a GET).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Contract lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
placed ──announce──▶ open (help/"help" window, default 60 min)
|
||||||
|
│ │
|
||||||
|
│ ├──▶ delivered (next safe tick: target active, no combat,
|
||||||
|
│ │ not in briefing/recap quiet window)
|
||||||
|
│ │ ├── target survives → SURVIVED payout + unseal buyer
|
||||||
|
│ │ └── target defeated → DOWNED consequences
|
||||||
|
│ └──▶ fizzled (expedition ended first → refund minus rake)
|
||||||
|
└── rejected at claim time (funds/eligibility) → never announced
|
||||||
|
```
|
||||||
|
|
||||||
|
- New table `mischief_contracts`: id, buyer_id, target_id, tier, fee, status
|
||||||
|
(`pending|open|delivered|fizzled|rejected`), signed (bool), escalation_count,
|
||||||
|
blessing json, source (`matrix|web`), created_at, window_ends_at, resolved_at.
|
||||||
|
Status transitions via conditional UPDATE (CAS), mirroring `deliverAmbient`.
|
||||||
|
- One live contract per target at a time; delivery fires from a sibling of the
|
||||||
|
ambient ticker after `window_ends_at`.
|
||||||
|
|
||||||
|
### The four tiers — PRICED (M0 done 2026-07-13)
|
||||||
|
|
||||||
|
Monster strength is **relative to the target**: pull from the target's own
|
||||||
|
bracket **zone pools** (grunt/mob = non-elite, elite = `IsElite`, boss = the
|
||||||
|
zone boss). NOT the arena ladder — arena `BaseLethality` is a legacy death
|
||||||
|
chance; arena T5 one-shots L20s and would make every tier an execution.
|
||||||
|
|
||||||
|
**M0 survival sweep** (n=2000/cell, `SimulateCombat` via the class-balance
|
||||||
|
harness builds at full HP, zone-pool monsters, ambush nick on elite/boss;
|
||||||
|
throwaway harness left skip-gated at
|
||||||
|
`internal/plugin/mischief_pricing_sweep_test.go`, env `MISCHIEF_SWEEP=1`):
|
||||||
|
|
||||||
|
| target build | grunt | mob (3 chained) | elite | boss |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| paladin L1 | 99.8% | 98.2% | 57.6% | 15.3% |
|
||||||
|
| mage L4 | 100% | 97.8% | 49.5% | 0.0% |
|
||||||
|
| mage L8 evoc | 100% | 100% | 56.5% | 0.4% |
|
||||||
|
| fighter L12 champ | 100% | 100% | 100% | 99.9% |
|
||||||
|
| rogue L20 AT (prosolis) | 100% | 100% | 100% | 35.9% |
|
||||||
|
| cleric L20 life (holymachina) | 100% | 100% | 100% | 14.1% |
|
||||||
|
|
||||||
|
Reading: grunt/mob are pure theater (+HP/supply attrition); elite is a real
|
||||||
|
coin-flip for at-bracket targets and trivial for overleveled martials; boss is
|
||||||
|
expedition-ending for casters at any level and a genuine 1-in-3 scare even for
|
||||||
|
the L20 rogue. Caveats: harness builds ≠ real sheets, full-HP-at-delivery is
|
||||||
|
optimistic (mid-run wounds raise real danger), no pets/party/consumables.
|
||||||
|
|
||||||
|
**Prod economy snapshot** (2026-07-13, 23 wallets): median balance ≈ €500;
|
||||||
|
whales prosolis €180k / holymachina €149k / nonk €62k hold 89% of all money.
|
||||||
|
Daily earn: whales ~€1.8–2.3k, mid-tier €20–500, casuals <€10. Reference
|
||||||
|
sinks: lottery ticket €100, duel escrow €1.5–5k, daily share ~€455, endgame
|
||||||
|
shop items €11–45k. Only 5 characters exist as targets (two L20s, L4, two
|
||||||
|
L1s) — the whales are both the richest buyers *and* the only high-bracket
|
||||||
|
targets, so boss-tier is effectively their PvP toy; casuals buy theater.
|
||||||
|
|
||||||
|
| Tier | Fee | Signed (+25%) | Survival payout (of fee) | Extras on survival |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| grunt | €40 | €50 | 40% → €16 | flavor line |
|
||||||
|
| mob | €100 | €125 | 50% → €50 | treasure roll (standard) |
|
||||||
|
| elite | €350 | €438 | 65% → €228 | treasure roll (elite) + renown |
|
||||||
|
| boss | €1,200 | €1,500 | 75% → €900 | elite treasure + consumable cache + renown + Pete milestone |
|
||||||
|
|
||||||
|
Rationale: grunt under the lottery-ticket impulse line so anyone can play;
|
||||||
|
mob = exactly one lottery ticket; elite ≈ one active day of mid-tier earnings
|
||||||
|
(a considered purchase, and its ~50% at-bracket survival odds are honest
|
||||||
|
stakes); boss in duel-stake territory — whale money, priced like the "end an
|
||||||
|
expedition" button it is against caster targets. Escalation cost = the
|
||||||
|
tier-delta fee; blessings €25 flat.
|
||||||
|
|
||||||
|
Anti-collusion invariant: **total payout (fee + escalations) capped at 75%**
|
||||||
|
in every case, so collusion is strictly dominated by `!baltransfer`, which is
|
||||||
|
free. No danger multiplier needed — the cap does all the work.
|
||||||
|
|
||||||
|
Money flow: survival payout to target, remainder → community pot. Defeat →
|
||||||
|
entire fee to community pot (never back to the buyer — glory only). Fizzle →
|
||||||
|
90% refund, 10% rake to pot.
|
||||||
|
|
||||||
|
Boss-tier friction (0–14% caster survival means "boss ≈ certain maiming"):
|
||||||
|
max 1 boss contract per target per week, on top of the global limits below.
|
||||||
|
|
||||||
|
### Death policy (recommendation: mischief maims, it doesn't murder)
|
||||||
|
|
||||||
|
`runHarvestInterrupt`'s loss path perma-kills (`abandonZoneRun` +
|
||||||
|
`forcedExtractExpedition` + mark dead). For a *purchased* attack that lands
|
||||||
|
while the victim is offline, perma-death feels like paid murder, not mischief.
|
||||||
|
Recommended: on defeat, apply the world-boss HP floor (`worldBossFloorHP`
|
||||||
|
pattern), force-extract the expedition (run-loss seam already exists), drop a
|
||||||
|
chunk of un-banked expedition coins/supplies, +threat. Character lives.
|
||||||
|
**Open decision** — could make boss tier lethal for stakes, but then it needs an
|
||||||
|
opt-in (hardcore flag) or it's a griefing lever.
|
||||||
|
|
||||||
|
### Anonymity + the unseal twist
|
||||||
|
|
||||||
|
Default: contracts are anonymous — "someone in town has put coin on
|
||||||
|
<target>'s head." Buyer can pay +25% to *sign* it (taunt rights).
|
||||||
|
**If the target survives, the contract is unsealed** — Pete names the buyer in
|
||||||
|
the survival bulletin. Risk of exposure is the natural brake on casual griefing,
|
||||||
|
and it makes survival announcements delicious.
|
||||||
|
|
||||||
|
### The games-room window (help or "help")
|
||||||
|
|
||||||
|
On placement, announce in `GAMES_ROOM` (world-boss style) + Pete priority beat.
|
||||||
|
During the window (default 60 min):
|
||||||
|
|
||||||
|
- `!mischief bless @target` — pay a small fee (~€15) for temp HP / +AC on the
|
||||||
|
incoming fight (well-rested-style bundle). Stacks, capped (say 3 blessings).
|
||||||
|
- `!mischief escalate @target` — pay ~half the next-tier delta to bump the
|
||||||
|
contract one tier, max one step total, boss can't escalate. Escalation money
|
||||||
|
joins the payout escrow — piling on raises the target's survival jackpot.
|
||||||
|
- Victim gets a TwinBee DM (first person, per voice rules): word has reached
|
||||||
|
them that someone wants them dead. Pure flavor — expeditions are autonomous —
|
||||||
|
but it lets them rally blessings.
|
||||||
|
|
||||||
|
Blessing/escalation state lives on the contract row (real rows, no phantom
|
||||||
|
per-fight state — lesson from the companion free-lunch bugs).
|
||||||
|
|
||||||
|
### Delivery mechanics
|
||||||
|
|
||||||
|
New `runMischiefInterrupt`, modeled line-for-line on `runHarvestInterrupt`:
|
||||||
|
pick monster per tier table → apply blessings to the roster → surprise nick
|
||||||
|
(attacker's privilege) → `runZoneCombatRoster(fightRoster(target), …)` → custom
|
||||||
|
close-out. Do **not** call `recordZoneKill`/advance zone state — the fight is
|
||||||
|
extrinsic to the dungeon. Party seats fight together and earn seat XP as usual;
|
||||||
|
the survival purse goes to the target (leader).
|
||||||
|
|
||||||
|
Fizzle: if the expedition ends before delivery, refund fee minus 10% rake
|
||||||
|
(deadpan Pete line: the monster arrived to an empty dungeon).
|
||||||
|
|
||||||
|
### Rate limits & eligibility (anti-grief)
|
||||||
|
|
||||||
|
- Target must have an active expedition **and** be level ≥ 3 (or similar floor).
|
||||||
|
- One live contract per target; per-target cooldown 24 h after resolution.
|
||||||
|
- Per-buyer cap: 2 contracts/day. Can't target yourself. Escrow debited at
|
||||||
|
placement via `euro.Debit` (respects the debt floor for free).
|
||||||
|
- Boredom-ticker auto-expeditions: **targetable** (they're real runs and it's
|
||||||
|
funny), but revisit if it feels bad in practice.
|
||||||
|
- Opt-out: none for v1 — being in the world means being in the world — but keep
|
||||||
|
the decision explicitly revisitable. News anonymization (`!news optout`)
|
||||||
|
still applies to Pete-facing names as it does today.
|
||||||
|
|
||||||
|
### Pete web storefront (the reverse pipe)
|
||||||
|
|
||||||
|
**Decision (2026-07-13): mischief UI requires Authentik sign-in on Pete.**
|
||||||
|
Pete's OIDC layer already exists (`[web.auth]`, disabled by default) and the
|
||||||
|
callback already parses `preferred_username` from the ID token
|
||||||
|
(`auth.go:236-250`) — it just isn't persisted into the `pete_session` cookie.
|
||||||
|
**VERIFIED 2026-07-13** on parodia.dev (`matrix-mas-1`,
|
||||||
|
`/home/reala/matrix/compose/mas/config.yaml`): MAS imports localpart with
|
||||||
|
`action: require, template: '{{ user.preferred_username }}'` — so
|
||||||
|
**Authentik username == Matrix localpart**, guaranteed. Identity is free:
|
||||||
|
add PreferredUsername to `SessionUser`, gogobee maps it to
|
||||||
|
`@<username>:<server>` (lowercase it — Matrix localparts must be lowercase,
|
||||||
|
Authentik usernames may not be). No link codes.
|
||||||
|
|
||||||
|
The one-way *network* rule (`roster.go:23-25`) stays intact — both new data
|
||||||
|
flows are gogobee-initiated:
|
||||||
|
|
||||||
|
- **Balances out (push):** gogobee already pushes a roster snapshot every
|
||||||
|
2 min; add euro-balance entries for linked users on the same tick. Pete
|
||||||
|
renders "~€X" and greys out unaffordable tiers. Advisory only; up to 2 min
|
||||||
|
stale is fine.
|
||||||
|
- **Orders in (poll):** signed-in buyer POSTs the order form → `mischief_orders`
|
||||||
|
row in pete.db keyed by preferred_username, status `pending`. gogobee's
|
||||||
|
peteclient grows a poll loop: `GET /api/mischief/pending` (bearer) every
|
||||||
|
30 s, claims each order (`POST /api/mischief/claim`, idempotent on order
|
||||||
|
GUID), then validates in-game.
|
||||||
|
- **Money truth lives only in gogobee:** the authoritative check is
|
||||||
|
`euro.Debit` at claim time. A stale web balance just means an occasional
|
||||||
|
"order bounced — insufficient funds" status (rendered from a fact) + TwinBee
|
||||||
|
DM to the buyer. Pete never writes a balance, so no double-spend surface.
|
||||||
|
|
||||||
|
Order targets: roster board (already live on `/adventure`) grows a "send
|
||||||
|
trouble" button per entry (roster token identifies the target — already
|
||||||
|
stable + non-deanonymizing). Pete renders order status from resulting facts,
|
||||||
|
never from live game state.
|
||||||
|
|
||||||
|
Ops note: enabling `[web.auth]` on prod Pete needs the Authentik OAuth client
|
||||||
|
provisioned + config.toml edit on the box (config is box-only).
|
||||||
|
|
||||||
|
### New Pete event types (deploy Pete FIRST — unknown types 400 → park forever)
|
||||||
|
|
||||||
|
`mischief_contract` (priority: a hit is out, tier, anonymous-or-signed),
|
||||||
|
`mischief_survived` (priority: target thwarted it — unseal buyer here),
|
||||||
|
`mischief_downed` (priority), `mischief_fizzled` (bulletin),
|
||||||
|
`mischief_link` (internal, no render — or handle out-of-band). Deadpan voice
|
||||||
|
throughout; check `adventure_flavor_*.go` for reusable lines before writing new
|
||||||
|
flavor (standing rule).
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
- **M0 — price the tiers. DONE 2026-07-13** (single-fight sweep + prod
|
||||||
|
economy snapshot; see tier table above). Follow-up for M1 close-out: re-run
|
||||||
|
the sweep through the *real* delivery path once `runMischiefInterrupt`
|
||||||
|
exists, since full-HP single fights understate mid-run danger.
|
||||||
|
- **M1 — core engine, Matrix-only. DONE 2026-07-13 (uncommitted, not deployed).**
|
||||||
|
`adventure_mischief.go` (tiers/pricing/persistence/eligibility/`!mischief`),
|
||||||
|
`adventure_mischief_deliver.go` (ticker + `runMischiefInterrupt` + close-outs),
|
||||||
|
`mischief_contracts` table, Pete's 4 `mischief_*` event types. 17 tests,
|
||||||
|
4 of them end-to-end through the real fight + euro + extraction.
|
||||||
|
|
||||||
|
Decisions taken during implementation (all inside the plan's intent):
|
||||||
|
- **Bracket monster-selection promoted out of the M0 test** into prod
|
||||||
|
(`mischiefBracketZone`/`mischiefMonsters`), so the sweep and the live
|
||||||
|
delivery now drive *the same* selection code — the fee table can't drift
|
||||||
|
away from the fight it priced.
|
||||||
|
- **Survival is read off the target's HP, not `PlayerWon`.** The engine's
|
||||||
|
timeout is a retreat, not a lethal blow: a target who ran out the clock
|
||||||
|
with HP left held the thing off, and a bought monster that merely outlasted
|
||||||
|
them hasn't earned a maiming. (M0 counted `!PlayerWon` as death, so real
|
||||||
|
survival rates are a touch *better* than priced. Pricing stands.)
|
||||||
|
- **No-perma-death extended to the whole party** (`floorMischiefRoster`, run
|
||||||
|
on BOTH outcomes). A leader can win a fight their friend went down in, and
|
||||||
|
the delivery skips `closeOutZoneWin` — without the floor, the member is left
|
||||||
|
alive at 0 HP, which every `HPCurrent <= 0` gate reads as broken.
|
||||||
|
- **One-live-contract-per-target is a partial UNIQUE INDEX**, not a read-then-
|
||||||
|
write check: placement holds only the *buyer's* lock, so two buyers racing at
|
||||||
|
one victim would both pass an in-code check. The loser is refunded.
|
||||||
|
- **Stale-delivery sweep** (`delivering` + 15 min grace → full refund). Without
|
||||||
|
it a crash mid-fight strands the row: target permanently un-targetable,
|
||||||
|
buyer's money gone.
|
||||||
|
- All contract timestamps bind as Go `time.Time` — never `CURRENT_TIMESTAMP`.
|
||||||
|
The driver stores RFC3339 (`2026-07-14T03:48:52Z`); a SQL-side stamp writes
|
||||||
|
`2026-07-14 03:48:52`, and the two compare lexicographically wrong.
|
||||||
|
- Fizzle DMs + rake, 90% refund; unstageable/stranded contracts refund 100%
|
||||||
|
(our fault, not a bet they lost).
|
||||||
|
|
||||||
|
**M1 close-out sweep DONE 2026-07-13** (`mischief_delivery_sweep_test.go`,
|
||||||
|
`MISCHIEF_SWEEP=1`, n=400/cell, 108 cells through the REAL delivery path:
|
||||||
|
`runMischiefInterrupt` → `runZoneCombatRoster`). Arms: entry HP 100/70/40%
|
||||||
|
(100% = control, reproduces M0 through the new path) × wards 0/3 on elite+boss.
|
||||||
|
|
||||||
|
| target | tier | 100% HP | 70% HP | 40% HP | 70% +3 wards |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| paladin L3 | elite | 84.8% | 73.2% | 62.7% | 80.5% |
|
||||||
|
| paladin L3 | boss | 87.8% | 45.8% | 8.0% | 88.0% |
|
||||||
|
| mage L4 | elite | 66.0% | 29.5% | 4.0% | 64.0% |
|
||||||
|
| mage L8 evoc | elite | 90.2% | 72.2% | 15.0% | 84.2% |
|
||||||
|
| mage L8 evoc | boss | 6.5% | 2.0% | 0.2% | 7.8% |
|
||||||
|
| fighter L12 | boss | 88.2% | 48.0% | 6.8% | 89.8% |
|
||||||
|
| rogue L20 | boss | 19.2% | 2.0% | 0.0% | 18.5% |
|
||||||
|
| cleric L20 | boss | 42.0% | 6.0% | 0.0% | 42.8% |
|
||||||
|
|
||||||
|
(grunt/mob: 100% for everyone except mage L4, who can lose a wounded mob.)
|
||||||
|
|
||||||
|
Three findings:
|
||||||
|
- **The M0 table was priced on a fight we don't deliver.** The control arm
|
||||||
|
diverges from M0 in BOTH directions: up where an engine timeout now counts as
|
||||||
|
survival (paladin boss 15→88%), and *down* where the turn engine loops a
|
||||||
|
boss's full multiattack profile and `SimulateCombat` doesn't (fighter boss
|
||||||
|
99.9→88%, rogue boss 36→19%) — see [[project_sim_multiattack_gap]]. Fees kept
|
||||||
|
(the tiers still do what they were priced to do); this table is now the
|
||||||
|
reference.
|
||||||
|
- **Wounding is the dominant variable, and M0 was blind to it.** Boss at a
|
||||||
|
realistic mid-run 70% HP collapses across the board (fighter 88→48, cleric
|
||||||
|
42→6, rogue 19→2); at 40% it is near-zero for everyone. Boss really is the
|
||||||
|
"end their expedition" button, as priced.
|
||||||
|
- **Wards were too cheap** (see M2 below): 3 of them buy ~+40pp.
|
||||||
|
- **M2 — the window. DONE 2026-07-13.** `!mischief bless @user` (€25, cap 3,
|
||||||
|
+10% MaxHP temp HP each) · `!mischief escalate @user` (pays the tier delta,
|
||||||
|
one step, boss is the ceiling) · victim DM on placement · escalator unsealed
|
||||||
|
alongside the buyer on survival. 6 new tests (22 total).
|
||||||
|
|
||||||
|
Decisions taken during implementation:
|
||||||
|
- **Blessing fee is a pure sink** (community pot), never a purse top-up. If a
|
||||||
|
ward raised the payout basis, warding a friend would become a money-routing
|
||||||
|
move; buying them HP can't be.
|
||||||
|
- **Ward price scales with the contract** — `max(€25, 10% of fee)`: grunt/mob
|
||||||
|
€25, elite €35, boss €120 (€360 to cover someone completely). The close-out
|
||||||
|
sweep measured 3 wards at ~+40pp (fighter vs boss at 70% HP: 48→90%), so a
|
||||||
|
flat €25 let the room halve a €1,200 boss contract for €75 — pocket change
|
||||||
|
against the tier the whole economy rests on. Reads the contract's *current*
|
||||||
|
basis, so an escalation raises the price of saving its target too. The €25
|
||||||
|
floor is knowingly above-market at grunt (3 wards > the €40 fee): nothing
|
||||||
|
needs warding against theatre, and the floor exists to keep a ward an impulse.
|
||||||
|
- **Escalation raises `fee` (the payout basis) AND `paid` together**, so the
|
||||||
|
75% cap and "purse < outlay" survive an escalation untouched — asserted.
|
||||||
|
- **Escalating into boss answers to the boss-per-target-per-week cap.**
|
||||||
|
Otherwise the cap is bought around for the price of an elite plus the delta.
|
||||||
|
- **Both window commands are CAS-then-refund**, like placement: the ward cap and
|
||||||
|
the one-step limit live in the UPDATE's WHERE clause, because a scramble is
|
||||||
|
exactly when four people press the button in the same second.
|
||||||
|
- **The delivery re-reads the contract AFTER claiming it.** The due-sweep's copy
|
||||||
|
is stale by construction — the window keeps writing to an open row up to the
|
||||||
|
claim, so a last-second escalation was paid for and then delivered the *old*
|
||||||
|
tier's monster. The claim is the fence. (Fixed + regression test.)
|
||||||
|
- **The ward is temp HP** (the well-rested mechanism), added to whatever cushion
|
||||||
|
the target already carried and subtracted again after the fight — a bought ward
|
||||||
|
must not eat a home long rest. It refreshes per link of the mob chain; that only
|
||||||
|
affects the one tier the sweep priced as theatre.
|
||||||
|
- Blessers are named on the spot (helping is proud); escalators are anonymous
|
||||||
|
until the survival unseals them, exactly like the buyer.
|
||||||
|
- Fizzled/unstageable contracts do **not** refund blessers. Their €25 was charity,
|
||||||
|
and it went to the pot. Revisit if it stings in practice.
|
||||||
|
- Fixed a pre-existing wall-clock flake in the M1 fizzle test: it drove
|
||||||
|
`fireMischiefDeliveries` with `time.Now()`, which refuses to run inside the
|
||||||
|
±1 h quiet windows around the 06:00 briefing and 21:00 recap.
|
||||||
|
- **M3 — Pete storefront (1 session, cross-repo).** Enable + extend OIDC
|
||||||
|
(persist preferred_username; provision Authentik client), orders table +
|
||||||
|
form + status rendering, balance push, gogobee poll/claim loop. First
|
||||||
|
visitor-facing write path on Pete — auth-gated + rate-limited per user.
|
||||||
|
(MAS localpart == Authentik preferred_username: verified 2026-07-13.)
|
||||||
|
- **M4 — polish.** Notoriety/renown for buyers, survival streaks → milestones,
|
||||||
|
digest lines, boredom-expedition policy review, tune fees from live data.
|
||||||
|
|
||||||
|
## Decisions — ALL CONFIRMED by reala 2026-07-13
|
||||||
|
|
||||||
|
1. **No perma-death anywhere** — mischief maims: HP floor + force-extract +
|
||||||
|
un-banked loot/supply loss + threat bump. Boss tier included.
|
||||||
|
2. **Anonymous by default; survival unseals the buyer** in Pete's bulletin;
|
||||||
|
+25% to sign openly.
|
||||||
|
3. **Fizzle refund 90%**, 10% rake to community pot.
|
||||||
|
4. **Targets: level ≥ 3 with an active expedition**; boredom auto-runs are
|
||||||
|
fair game. One live contract per target, 24 h post-resolution cooldown,
|
||||||
|
2 contracts/day per buyer, 1 boss/target/week.
|
||||||
|
5. **Fee/payout table as priced above** (M0 sweep + prod economy).
|
||||||
|
|
||||||
|
## Standing-rule checklist
|
||||||
|
|
||||||
|
- No new `dnd_`-prefixed files/tables → `adventure_mischief*.go`, `mischief_*` tables.
|
||||||
|
- TwinBee first-person; Pete deadpan third-person announcer.
|
||||||
|
- Plan doc stays working-tree only.
|
||||||
|
- Pete deploys before gogobee whenever event types change.
|
||||||
|
- Sim sweeps: control arm + n≥750; run heavy sweeps on Parodia/millenia.
|
||||||
|
- Any loader/bootstrap added must keep its backfill as a one-shot bootstrap.
|
||||||
267
gogobee_revisit_plan.md
Normal file
267
gogobee_revisit_plan.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# Revisit / Backtrack Navigation — Design Plan
|
||||||
|
|
||||||
|
Lets players walk back to previously visited rooms within an active zone
|
||||||
|
run to re-harvest, re-fork (e.g. after acquiring a key), or pick up
|
||||||
|
something they skipped. Forward-only navigation has been the rule since
|
||||||
|
Phase G; this plan retires that assumption deliberately.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Re-harvest depleted nodes after a long rest (primary use case).
|
||||||
|
- Allow general exploration freedom — wander back, look around.
|
||||||
|
- Re-pick a fork after acquiring a key or changing strategy.
|
||||||
|
- Preserve the existing pacing pressure (threat clock, SU drain) but
|
||||||
|
make backtracking *cheaper* than fresh exploration to reflect that
|
||||||
|
the route is already known.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Teleporting to arbitrary discovered rooms.
|
||||||
|
- Backtracking during `!expedition run` autopilot (autopilot is
|
||||||
|
forward-only by design).
|
||||||
|
- Re-fighting cleared encounters or re-arming disarmed traps.
|
||||||
|
|
||||||
|
## Player-facing surface
|
||||||
|
|
||||||
|
- **`!revisit <N>`** — walks one edge from the current node toward a
|
||||||
|
previously-visited node. `N` is the 1-indexed room number printed in
|
||||||
|
`!map`'s Path strip.
|
||||||
|
- Must be in `VisitedNodes`.
|
||||||
|
- Must be adjacent to `CurrentNode` via an edge in the zone graph
|
||||||
|
(forward or reverse direction; graph edges are directed but we
|
||||||
|
allow reverse traversal for revisit).
|
||||||
|
- Costs threat and SU at a reduced rate (see Cost Model).
|
||||||
|
- No combat re-roll, no trap re-arm in cleared rooms.
|
||||||
|
|
||||||
|
- **`!map`** — already shows the numbered Path strip; players read N
|
||||||
|
from there.
|
||||||
|
|
||||||
|
- **`!zone advance`** post-revisit — unchanged. From any node, picks
|
||||||
|
the first outgoing edge (or shows the fork prompt). Re-forking a
|
||||||
|
previously-resolved fork node clears the prior choice silently.
|
||||||
|
|
||||||
|
## Cost model
|
||||||
|
|
||||||
|
> **CORRECTED 2026-07-09 during R1.** The table below originally claimed a
|
||||||
|
> fresh `!zone advance` costs +2 threat / −1.0 SU. It does not. Verified at
|
||||||
|
> HEAD: **movement itself is free.** There is no per-room tick anywhere.
|
||||||
|
> - **Supplies burn per *day*,** not per room — `applyDailyBurn` is called
|
||||||
|
> only from `dnd_expedition_cycle.go:108` (the day rollover),
|
||||||
|
> `dnd_expedition_extract.go:52`, and region transit. Never from advance.
|
||||||
|
> - **Threat comes from *combat*,** not from walking:
|
||||||
|
> `applyRoomCombatThreatForUser` (+5 normal, +8 elite/boss) fires from the
|
||||||
|
> three combat-resolution sites, plus daily drift, harvest noise (+2), and
|
||||||
|
> ambient. `zoneCmdAdvance` / `advanceOnce` touch neither clock.
|
||||||
|
>
|
||||||
|
> This inverts R2's central premise. A revisited room fires no combat
|
||||||
|
> (terminal `CombatSession` rows gate re-entry), so backtracking is already
|
||||||
|
> free — the design problem is not *discounting* a cost, it's that there is
|
||||||
|
> **no pacing pressure to discount**. Whatever R2 charges is a net-new cost.
|
||||||
|
|
||||||
|
| Move | Threat tick | SU tick |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| Fresh-room `!zone advance` | **0** (actual) | **0** (actual) |
|
||||||
|
| Combat resolved in a room | +5, +8 elite/boss | 0 |
|
||||||
|
| Day rollover | drift (mood-scaled) | −daily burn |
|
||||||
|
| `!revisit <N>` (one edge) | **TBD — see below** | n/a (no per-move SU exists) |
|
||||||
|
|
||||||
|
**DECIDED 2026-07-09 — option (1), +1 threat per revisit edge.** Shipped in
|
||||||
|
R2 as `revisitThreatCost`. Standalone (non-expedition) zone runs have no
|
||||||
|
threat clock and so pay nothing. A `revisitSiegeGuard` refuses the move at
|
||||||
|
threat ≥ 99: siege is one-way sticky, and a player must not be able to strand
|
||||||
|
their own expedition on a move they made to go pick up a herb.
|
||||||
|
|
||||||
|
Options as they were weighed, cheapest first:
|
||||||
|
1. **Charge +1 threat per revisit edge.** One `applyThreatDelta` call,
|
||||||
|
mirrors "harvest noise". Reads as "you stir up attention doubling back."
|
||||||
|
Cheap, honest, and it's the only clock revisit can plausibly touch.
|
||||||
|
2. **Charge nothing.** Backtracking is already free of combat; the real
|
||||||
|
cost is the day clock, which a long detour burns anyway. Simplest, and
|
||||||
|
arguably correct — the pacing pressure is the multi-day supply budget,
|
||||||
|
not the step count.
|
||||||
|
3. **Charge a fractional day.** Rejected: the day counter is the spine of
|
||||||
|
every milestone, digest and anchored event. Don't make it fractional.
|
||||||
|
|
||||||
|
Recommend **(1)** at +1: it's the smallest lever that makes a detour a real
|
||||||
|
choice, and it rides an existing, tested seam.
|
||||||
|
|
||||||
|
## State model
|
||||||
|
|
||||||
|
The hard problem: `CurrentRoom` is currently derived as
|
||||||
|
`len(VisitedNodes)-1` (dnd_zone_run.go:377), which assumes
|
||||||
|
monotonically-growing visit history. Backtracking breaks that.
|
||||||
|
|
||||||
|
### Schema changes
|
||||||
|
|
||||||
|
- **`VisitedNodes`** — unchanged semantics: ordered set of nodes ever
|
||||||
|
entered, first-entry order. Stays append-only when entering a *new*
|
||||||
|
node; revisits do not append.
|
||||||
|
- **`CurrentNode`** — already the source-of-truth for "where am I."
|
||||||
|
- **`CurrentRoom`** — repurpose from `len(VisitedNodes)-1` to "index of
|
||||||
|
CurrentNode within VisitedNodes" (the room's first-entry index, which
|
||||||
|
is the path-relative label the player sees).
|
||||||
|
- **`RoomsTraversed`** (NEW, int column on `dnd_zone_run`) — monotonic
|
||||||
|
step counter. Drives threat/SU ticks. Bootstrapped to
|
||||||
|
`len(VisitedNodes)` for existing rows.
|
||||||
|
|
||||||
|
### Audit pass required
|
||||||
|
|
||||||
|
Every read of `r.CurrentRoom` and `len(r.VisitedNodes)` needs to be
|
||||||
|
classified:
|
||||||
|
|
||||||
|
- **"Path index" reads (keep as CurrentRoom)**: display headers
|
||||||
|
("Room 3/7"), encounter ID derivation (`encounterIDForRoom`), enemy
|
||||||
|
salt seeds, harvest room keys, status/abandon strings, camp room
|
||||||
|
index.
|
||||||
|
- **"Progress counter" reads (switch to RoomsTraversed)**: threat
|
||||||
|
clock ticks, supplies ticks, ambient narration cadence, autopilot
|
||||||
|
preflight, fatigue/exhaustion accrual if any.
|
||||||
|
|
||||||
|
Concrete callsite list will be produced during implementation; estimate
|
||||||
|
~10–15 callsites total.
|
||||||
|
|
||||||
|
## Fork re-pick
|
||||||
|
|
||||||
|
Re-entering a fork node clears its resolved choice in `node_choices`
|
||||||
|
(or equivalent). Next `!zone advance` shows the fork prompt fresh,
|
||||||
|
evaluated against current inventory (so a newly-acquired key unlocks
|
||||||
|
previously-locked edges).
|
||||||
|
|
||||||
|
Silent — no warning. Graph-back navigation means the player has
|
||||||
|
walked back through their previously-chosen path; nothing in
|
||||||
|
`VisitedNodes` is destroyed; the alternate branch they previously
|
||||||
|
took is still revisitable. There is no "discard" semantics.
|
||||||
|
|
||||||
|
## RoomsCleared semantics
|
||||||
|
|
||||||
|
Idempotent — exiting a room a second time doesn't re-append to
|
||||||
|
`RoomsCleared`. The "advanced past" flag is sticky.
|
||||||
|
|
||||||
|
## Preflight checks (rejections)
|
||||||
|
|
||||||
|
`!revisit` rejects with a clear DM when:
|
||||||
|
|
||||||
|
- Target room is not in `VisitedNodes` ("You haven't been there yet.")
|
||||||
|
- Target equals `CurrentNode` (no-op)
|
||||||
|
- Target is not adjacent to `CurrentNode` via any graph edge
|
||||||
|
("Room X isn't directly connected — backtrack one step at a time.")
|
||||||
|
- Active combat session in current room
|
||||||
|
("Finish the fight first.")
|
||||||
|
- Threat clock would tick past max (use existing camp-eligibility
|
||||||
|
preflight pattern)
|
||||||
|
- SU would drop below survival floor (same)
|
||||||
|
- Character is dead / expedition is paused (same gates as `!zone advance`)
|
||||||
|
- Autopilot run is active
|
||||||
|
|
||||||
|
## Rollout phases
|
||||||
|
|
||||||
|
Tentative ordering. Each phase ships independently.
|
||||||
|
|
||||||
|
### R1 — schema + audit ✅ **DONE 2026-07-09** (branch `n1-restoration`)
|
||||||
|
|
||||||
|
Shipped: `rooms_traversed` column + `bootstrapRoomsTraversed` backfill;
|
||||||
|
`CurrentRoom` re-derived via `pathIndexOf(VisitedNodes, CurrentNode)`;
|
||||||
|
`appendVisited` / `appendClearedRoom` made set-semantic; `narrationCadence`
|
||||||
|
routed off `RoomsTraversed`; `advanceZoneRunNode` now returns the moved-to
|
||||||
|
path index. Tests in `zone_revisit_r1_test.go`. Full suite green. No
|
||||||
|
player-visible behavior change.
|
||||||
|
|
||||||
|
**Audit outcome — the callsite split was smaller than estimated (~10–15).**
|
||||||
|
Almost every `CurrentRoom` read is a *path index* and correctly stays put:
|
||||||
|
enemy/trap salts (`pickZoneEnemy`, `pickZoneTrap`, `rollTrapDamage`), loot
|
||||||
|
RNG seed, `encounterIDForRoom`, harvest room keys, camp `RoomIndex`, map
|
||||||
|
render, "Room X/Y" headers. Keying those on the node's first-entry index is
|
||||||
|
exactly what makes a revisited room resolve to *the same room*.
|
||||||
|
|
||||||
|
Only two reads were progress-shaped:
|
||||||
|
- `narrationCadence` → now `RoomsTraversed-1`, so a backtracking player
|
||||||
|
draws fresh flavor instead of replaying the lines they read on the way in.
|
||||||
|
- `nextIdx := run.CurrentRoom + 1` (`dnd_zone_cmd_graph.go`) — not a
|
||||||
|
progress read but a latent bug: "+1" only labels correctly while the
|
||||||
|
player is at the frontier. `advanceZoneRunNode` now returns the true index.
|
||||||
|
|
||||||
|
**Nothing to route off `RoomsTraversed` for threat/SU** — there are no
|
||||||
|
per-room ticks to route (see the corrected Cost model above). The counter
|
||||||
|
currently drives narration cadence and stands ready for R2's cost decision.
|
||||||
|
|
||||||
|
Two behaviors were hardened in passing, both no-ops under forward-only
|
||||||
|
navigation but load-bearing the moment R2 lands:
|
||||||
|
- `VisitedNodes` is an ordered **set** — a re-entered node is not
|
||||||
|
re-appended, so room numbers never renumber under the player.
|
||||||
|
- `RoomsCleared` is **idempotent** — exiting a room twice can't inflate it
|
||||||
|
past `TotalRooms` and skew the "N/M rooms" renders.
|
||||||
|
|
||||||
|
### R2 — `!revisit` command ✅ **DONE 2026-07-09** (branch `n1-restoration`)
|
||||||
|
|
||||||
|
Shipped: `!revisit <N>` (alias `!zone revisit`), `adjacentNodes` reverse-edge
|
||||||
|
traversal, full preflight, +1 threat, `!map` now prints a **Back to:** strip
|
||||||
|
of legal targets. Fork re-pick still deferred to R3 — revisit refuses while a
|
||||||
|
fork prompt is pending, because both `!zone advance` and `!zone go` resolve
|
||||||
|
`node_choices` without checking which node the player is standing on.
|
||||||
|
|
||||||
|
**"No combat/trap re-trigger logic needed" was wrong — and it was an exploit.**
|
||||||
|
Terminal `CombatSession` rows gate *only* Elite and Boss rooms, at the doorway
|
||||||
|
check in `advanceOnceWithOpts`. An Exploration room re-enters
|
||||||
|
`resolveCombatRoom` unconditionally and a Trap room re-arms. Left alone,
|
||||||
|
`!revisit` would have been an infinite farm: step back one room, `!zone
|
||||||
|
advance`, repeat for loot and XP. Fixed with a `RoomIsCleared(CurrentRoom)`
|
||||||
|
early-return at the top of `resolveRoom` — a no-op forward-only, since advance
|
||||||
|
clears a room only *after* resolving it. Guarded by
|
||||||
|
`TestRevisitedRoom_DoesNotReResolve` and `TestFreshRoom_StillResolves`.
|
||||||
|
|
||||||
|
**Autopilot needed a stopgap, sooner than R5 planned.** Autopilot has no
|
||||||
|
on/off switch — `tryAutoRun` is ticker-driven for every active expedition on a
|
||||||
|
2h CAS cooldown. Without intervention the background walker marches the player
|
||||||
|
straight back out of the room they just revisited, and the whole feature reads
|
||||||
|
as broken. `grantAutorunGrace` stamps `last_autorun_at` on revisit, buying one
|
||||||
|
full cooldown window. **R5 still owes the real policy decision** (refuse to
|
||||||
|
auto-walk from a non-frontier node, vs. walk back to the frontier first).
|
||||||
|
|
||||||
|
Tests in `zone_revisit_r2_test.go`. Full suite green.
|
||||||
|
|
||||||
|
### R3 — fork re-pick
|
||||||
|
|
||||||
|
- On revisit landing into a fork node, clear `node_choices` for that
|
||||||
|
node.
|
||||||
|
- Next advance re-prompts.
|
||||||
|
- Tests: lock-with-key acquired between two fork visits, alternate
|
||||||
|
branch selection, no-change re-pick.
|
||||||
|
|
||||||
|
### R4 — UX polish
|
||||||
|
|
||||||
|
- DM line on revisit explaining the cost ("You retrace your steps to
|
||||||
|
Room 3 (the fork). Threat +1, SU −0.5.").
|
||||||
|
- Surface `!revisit` in `!help` and `!zone` help blocks.
|
||||||
|
- Ambient/narration audit: any "you press deeper into the dungeon"
|
||||||
|
lines that no longer fit when player is backtracking.
|
||||||
|
|
||||||
|
### R5 — autopilot guard
|
||||||
|
|
||||||
|
- `!expedition run` refuses to start while at a non-frontier node?
|
||||||
|
Or auto-walks back to frontier first? Decide based on R1–R4 feel.
|
||||||
|
Likely: refuse with a "use !zone advance to return to the frontier
|
||||||
|
first" hint.
|
||||||
|
|
||||||
|
## Open questions (lower priority)
|
||||||
|
|
||||||
|
- Should there be a small flavor-only narrative beat the first time
|
||||||
|
the player backtracks in an expedition? ("TwinBee notes you doubling
|
||||||
|
back — there's no shame in it. Sometimes the answer was in a room
|
||||||
|
you'd already passed.")
|
||||||
|
- Does a babysitter's safe-rest affect revisit cost? (Probably not —
|
||||||
|
babysitter is a camp upgrade, revisit is movement.)
|
||||||
|
- Multi-region expeditions — does revisit work across region
|
||||||
|
boundaries within the same expedition? (Initial answer: no, only
|
||||||
|
within the current zone run; cross-region travel is its own seam.)
|
||||||
|
|
||||||
|
## Effort estimate
|
||||||
|
|
||||||
|
Comparable to a small Phase pass (G6-sized). Roughly:
|
||||||
|
|
||||||
|
- R1: 1 session (schema bump + audit + tests).
|
||||||
|
- R2: 1 session.
|
||||||
|
- R3: 0.5 session.
|
||||||
|
- R4: 0.5 session.
|
||||||
|
- R5: 0.5 session.
|
||||||
|
|
||||||
|
Total: ~3–4 working sessions.
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
@@ -107,7 +106,19 @@ func newAppserviceSession(cfg Config) (*Session, error) {
|
|||||||
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
|
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve room state (is it encrypted, who is in it) from the server on first
|
||||||
|
// use, since there is no /sync to backfill it. Must be installed before the
|
||||||
|
// first BotClient() call: makeClient copies as.StateStore into the client, and
|
||||||
|
// caches the client.
|
||||||
|
inner, ok := as.StateStore.(innerStateStore)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("appservice state store %T does not implement crypto.StateStore", as.StateStore)
|
||||||
|
}
|
||||||
|
store := newLazyStateStore(inner)
|
||||||
|
as.StateStore = store
|
||||||
|
|
||||||
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
|
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
|
||||||
|
store.client = client
|
||||||
// Assert the device via ?org.matrix.msc3202.device_id= on E2EE requests, and
|
// Assert the device via ?org.matrix.msc3202.device_id= on E2EE requests, and
|
||||||
// satisfy cryptohelper.Init's syncer check: with this set, Init permits a nil
|
// satisfy cryptohelper.Init's syncer check: with this set, Init permits a nil
|
||||||
// Syncer (we drive the crypto machine from transactions, not /sync). The param
|
// Syncer (we drive the crypto machine from transactions, not /sync). The param
|
||||||
@@ -172,29 +183,6 @@ func newAppserviceSession(cfg Config) (*Session, error) {
|
|||||||
mach.HandleMemberEvent(ctx, evt)
|
mach.HandleMemberEvent(ctx, evt)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Without /sync there is no state backfill, so the client's StateStore starts
|
|
||||||
// empty. Before we hand off a decrypted event (whose reply may need to be
|
|
||||||
// encrypted), resolve the room once: mark it encrypted and populate its member
|
|
||||||
// list. Otherwise outbound sends go plaintext (IsEncrypted=false) and, worse,
|
|
||||||
// the group session is shared to nobody (GetRoomJoinedOrInvitedMembers empty)
|
|
||||||
// so recipients can't decrypt the bot's replies.
|
|
||||||
var resolved sync.Map // roomID -> struct{}, resolved once
|
|
||||||
resolveRoom := func(ctx context.Context, roomID id.RoomID) {
|
|
||||||
if _, done := resolved.LoadOrStore(roomID, struct{}{}); done {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var enc event.EncryptionEventContent
|
|
||||||
if err := client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc); err == nil && enc.Algorithm != "" {
|
|
||||||
_ = as.StateStore.SetEncryptionEvent(ctx, roomID, &enc)
|
|
||||||
}
|
|
||||||
if members, err := client.Members(ctx, roomID); err == nil {
|
|
||||||
_ = as.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk)
|
|
||||||
} else {
|
|
||||||
slog.Warn("appservice: failed to fetch room members; will retry", "room", roomID, "err", err)
|
|
||||||
resolved.Delete(roomID) // allow a later event to retry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recoverSession handles an event whose megolm session we don't have yet. The
|
// recoverSession handles an event whose megolm session we don't have yet. The
|
||||||
// keys are often merely in flight (a to-device m.room_key racing the room
|
// keys are often merely in flight (a to-device m.room_key racing the room
|
||||||
// event), so wait briefly; if they never land, ask the sender to re-share and
|
// event), so wait briefly; if they never land, ask the sender to re-share and
|
||||||
@@ -236,7 +224,6 @@ func newAppserviceSession(cfg Config) (*Session, error) {
|
|||||||
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
|
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
|
||||||
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
|
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
|
||||||
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
|
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
|
||||||
resolveRoom(ctx, evt.RoomID)
|
|
||||||
decrypted, err := ch.Decrypt(ctx, evt)
|
decrypted, err := ch.Decrypt(ctx, evt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, cryptohelper.NoSessionFound) {
|
if errors.Is(err, cryptohelper.NoSessionFound) {
|
||||||
|
|||||||
164
internal/bot/statestore.go
Normal file
164
internal/bot/statestore.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/appservice"
|
||||||
|
"maunium.net/go/mautrix/crypto"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lazyStateStore resolves room state from the homeserver on first use instead of
|
||||||
|
// waiting for it to arrive over a sync.
|
||||||
|
//
|
||||||
|
// The appservice has no /sync, so the state store only ever learns a room is
|
||||||
|
// encrypted from an m.room.encryption event pushed in a transaction — which for
|
||||||
|
// an already-configured room never happens again. Anything the bot says on its
|
||||||
|
// own initiative (ambient DMs, expedition beats, briefings) therefore reached
|
||||||
|
// SendMessageEvent with IsEncrypted=false and went out as plaintext into an
|
||||||
|
// encrypted room. The state store being in-memory made every restart a fresh
|
||||||
|
// chance to leak.
|
||||||
|
//
|
||||||
|
// Both reads the send path depends on are resolved here on the first miss and
|
||||||
|
// cached: whether the room is encrypted, and who to share the group session with
|
||||||
|
// (an empty member list encrypts to nobody, which is its own outage). Neither
|
||||||
|
// read is allowed to fail open — a lookup that cannot be resolved returns an
|
||||||
|
// error, which aborts the send. A failed message is recoverable; a plaintext one
|
||||||
|
// that has already landed in an encrypted room is not.
|
||||||
|
type lazyStateStore struct {
|
||||||
|
// Embeds both interfaces the store is consumed through: the appservice
|
||||||
|
// dispatches state updates through the first, and the crypto machine type-
|
||||||
|
// asserts the client's store to the second (cryptohelper.NewCryptoHelper
|
||||||
|
// rejects a store that fails the assertion). Embedding only the appservice
|
||||||
|
// interface silently drops crypto's extra methods from the concrete type.
|
||||||
|
innerStateStore
|
||||||
|
|
||||||
|
client *mautrix.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
locks map[id.RoomID]*sync.Mutex
|
||||||
|
// Rooms whose m.room.encryption state we have asked the server about. The
|
||||||
|
// underlying store cannot distinguish "not encrypted" from "never heard of
|
||||||
|
// it", so the distinction is tracked here.
|
||||||
|
encryptionResolved map[id.RoomID]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// innerStateStore is the full method set the wrapped store must provide.
|
||||||
|
type innerStateStore interface {
|
||||||
|
appservice.StateStore
|
||||||
|
crypto.StateStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wrapper is consumed through both, and losing either one is a startup
|
||||||
|
// failure rather than a build failure without these.
|
||||||
|
var (
|
||||||
|
_ appservice.StateStore = (*lazyStateStore)(nil)
|
||||||
|
_ crypto.StateStore = (*lazyStateStore)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLazyStateStore(inner innerStateStore) *lazyStateStore {
|
||||||
|
return &lazyStateStore{
|
||||||
|
innerStateStore: inner,
|
||||||
|
locks: make(map[id.RoomID]*sync.Mutex),
|
||||||
|
encryptionResolved: make(map[id.RoomID]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockRoom serialises resolution per room so a burst of sends into a cold room
|
||||||
|
// makes one request rather than one per message.
|
||||||
|
func (s *lazyStateStore) lockRoom(roomID id.RoomID) func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
lock, ok := s.locks[roomID]
|
||||||
|
if !ok {
|
||||||
|
lock = &sync.Mutex{}
|
||||||
|
s.locks[roomID] = lock
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
lock.Lock()
|
||||||
|
return lock.Unlock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lazyStateStore) isEncryptionResolved(roomID id.RoomID) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.encryptionResolved[roomID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lazyStateStore) markEncryptionResolved(roomID id.RoomID) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.encryptionResolved[roomID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEncrypted answers from the underlying store once the room's encryption state
|
||||||
|
// has been resolved, fetching it from the server the first time.
|
||||||
|
func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) {
|
||||||
|
if !s.isEncryptionResolved(roomID) {
|
||||||
|
unlock := s.lockRoom(roomID)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
if !s.isEncryptionResolved(roomID) {
|
||||||
|
var enc event.EncryptionEventContent
|
||||||
|
err := s.client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if enc.Algorithm != "" {
|
||||||
|
if err := s.innerStateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil {
|
||||||
|
return false, fmt.Errorf("cache encryption state for %s: %w", roomID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case errors.Is(err, mautrix.MNotFound):
|
||||||
|
// No m.room.encryption at all: the room really is unencrypted.
|
||||||
|
default:
|
||||||
|
// Unresolved. Refuse rather than guess "unencrypted" and leak.
|
||||||
|
return false, fmt.Errorf("resolve encryption state for %s: %w", roomID, err)
|
||||||
|
}
|
||||||
|
s.markEncryptionResolved(roomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.innerStateStore.IsEncrypted(ctx, roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEncryptionEvent is how the crypto machine reads a room's megolm settings
|
||||||
|
// (session rotation period, etc). It needs the same resolution as IsEncrypted,
|
||||||
|
// since an unresolved room would otherwise report no encryption event.
|
||||||
|
func (s *lazyStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) {
|
||||||
|
if _, err := s.IsEncrypted(ctx, roomID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.innerStateStore.GetEncryptionEvent(ctx, roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoomJoinedOrInvitedMembers backs megolm session sharing. Without a sync
|
||||||
|
// there is no member backfill, so an unfetched room would report zero members
|
||||||
|
// and the bot would encrypt to an audience of nobody.
|
||||||
|
func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) {
|
||||||
|
fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
|
||||||
|
}
|
||||||
|
if !fetched {
|
||||||
|
unlock := s.lockRoom(roomID)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
if fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID); err != nil {
|
||||||
|
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
|
||||||
|
} else if !fetched {
|
||||||
|
members, err := s.client.Members(ctx, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch members of %s: %w", roomID, err)
|
||||||
|
}
|
||||||
|
// No membership filter, so this also marks the room as fetched.
|
||||||
|
if err := s.innerStateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil {
|
||||||
|
return nil, fmt.Errorf("cache members of %s: %w", roomID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.innerStateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID)
|
||||||
|
}
|
||||||
195
internal/bot/statestore_test.go
Normal file
195
internal/bot/statestore_test.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeHS serves the two endpoints the lazy store resolves against, counting hits
|
||||||
|
// so we can assert it caches instead of re-asking on every send.
|
||||||
|
type fakeHS struct {
|
||||||
|
encryptionStatus int
|
||||||
|
encryptionBody string
|
||||||
|
membersBody string
|
||||||
|
|
||||||
|
encryptionHits atomic.Int32
|
||||||
|
memberHits atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeHS) start(t *testing.T) *mautrix.Client {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/state/m.room.encryption"):
|
||||||
|
f.encryptionHits.Add(1)
|
||||||
|
w.WriteHeader(f.encryptionStatus)
|
||||||
|
_, _ = w.Write([]byte(f.encryptionBody))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/members"):
|
||||||
|
f.memberHits.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(f.membersBody))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request to %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
client, err := mautrix.NewClient(srv.URL, "@bot:example.org", "token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new client: %v", err)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestStore(t *testing.T, hs *fakeHS) *lazyStateStore {
|
||||||
|
t.Helper()
|
||||||
|
store := newLazyStateStore(mautrix.NewMemoryStateStore().(innerStateStore))
|
||||||
|
store.client = hs.start(t)
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
// The store is handed to the crypto helper, which type-asserts it and refuses to
|
||||||
|
// start if it comes up short. That assertion is the whole bot's startup path, so
|
||||||
|
// pin it here rather than discovering it as a crash loop in prod.
|
||||||
|
func TestStoreSatisfiesCryptoHelper(t *testing.T) {
|
||||||
|
store := newTestStore(t, &fakeHS{})
|
||||||
|
|
||||||
|
client := store.client
|
||||||
|
client.StateStore = store
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := cryptohelper.NewCryptoHelper(client, []byte("test"), filepath.Join(dir, "crypto.db")); err != nil {
|
||||||
|
t.Fatalf("crypto helper rejected the state store, so the bot would not start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomID = id.RoomID("!room:example.org")
|
||||||
|
|
||||||
|
// The regression: a room configured as encrypted before the process started. No
|
||||||
|
// transaction ever re-announces it, so the store must go ask.
|
||||||
|
func TestIsEncryptedResolvesFromServer(t *testing.T) {
|
||||||
|
hs := &fakeHS{
|
||||||
|
encryptionStatus: http.StatusOK,
|
||||||
|
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||||
|
}
|
||||||
|
store := newTestStore(t, hs)
|
||||||
|
|
||||||
|
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsEncrypted: %v", err)
|
||||||
|
}
|
||||||
|
if !enc {
|
||||||
|
t.Fatal("room is encrypted on the server but IsEncrypted said false — this is the plaintext leak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A room with no m.room.encryption really is plaintext, and must not re-fetch.
|
||||||
|
func TestIsEncryptedCachesUnencrypted(t *testing.T) {
|
||||||
|
hs := &fakeHS{
|
||||||
|
encryptionStatus: http.StatusNotFound,
|
||||||
|
encryptionBody: `{"errcode":"M_NOT_FOUND","error":"Event not found."}`,
|
||||||
|
}
|
||||||
|
store := newTestStore(t, hs)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsEncrypted: %v", err)
|
||||||
|
}
|
||||||
|
if enc {
|
||||||
|
t.Fatal("unencrypted room reported as encrypted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := hs.encryptionHits.Load(); got != 1 {
|
||||||
|
t.Errorf("expected the M_NOT_FOUND to be cached, got %d fetches", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The important half: an unresolvable room must fail the send, not quietly
|
||||||
|
// answer "not encrypted" and let the message out in the clear.
|
||||||
|
func TestIsEncryptedFailsClosed(t *testing.T) {
|
||||||
|
hs := &fakeHS{
|
||||||
|
encryptionStatus: http.StatusInternalServerError,
|
||||||
|
encryptionBody: `{"errcode":"M_UNKNOWN","error":"oops"}`,
|
||||||
|
}
|
||||||
|
store := newTestStore(t, hs)
|
||||||
|
|
||||||
|
if _, err := store.IsEncrypted(context.Background(), roomID); err == nil {
|
||||||
|
t.Fatal("a failed lookup returned no error; the send would proceed in plaintext")
|
||||||
|
}
|
||||||
|
// The failure must not poison the cache: a later send has to retry.
|
||||||
|
hs.encryptionStatus = http.StatusOK
|
||||||
|
hs.encryptionBody = `{"algorithm":"m.megolm.v1.aes-sha2"}`
|
||||||
|
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsEncrypted after recovery: %v", err)
|
||||||
|
}
|
||||||
|
if !enc {
|
||||||
|
t.Fatal("store did not retry after a transient failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A burst of proactive sends into a cold room should resolve it once.
|
||||||
|
func TestIsEncryptedResolvesOncePerRoom(t *testing.T) {
|
||||||
|
hs := &fakeHS{
|
||||||
|
encryptionStatus: http.StatusOK,
|
||||||
|
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||||
|
}
|
||||||
|
store := newTestStore(t, hs)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := store.IsEncrypted(context.Background(), roomID); err != nil {
|
||||||
|
t.Errorf("IsEncrypted: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := hs.encryptionHits.Load(); got != 1 {
|
||||||
|
t.Errorf("expected 1 state fetch for 20 concurrent sends, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypting to an empty member list would share the group session with nobody,
|
||||||
|
// so members must be resolved too.
|
||||||
|
func TestMembersResolveFromServer(t *testing.T) {
|
||||||
|
hs := &fakeHS{
|
||||||
|
encryptionStatus: http.StatusOK,
|
||||||
|
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||||
|
membersBody: `{"chunk":[
|
||||||
|
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@alice:example.org","sender":"@alice:example.org","content":{"membership":"join"}},
|
||||||
|
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@bot:example.org","sender":"@bot:example.org","content":{"membership":"join"}}
|
||||||
|
]}`,
|
||||||
|
}
|
||||||
|
store := newTestStore(t, hs)
|
||||||
|
|
||||||
|
members, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRoomJoinedOrInvitedMembers: %v", err)
|
||||||
|
}
|
||||||
|
if len(members) != 2 {
|
||||||
|
t.Fatalf("expected 2 members, got %d (%v) — the group session would be shared to nobody", len(members), members)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID); err != nil {
|
||||||
|
t.Fatalf("second call: %v", err)
|
||||||
|
}
|
||||||
|
if got := hs.memberHits.Load(); got != 1 {
|
||||||
|
t.Errorf("expected members to be cached after one fetch, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -306,6 +306,18 @@ func runMigrations(d *sql.DB) error {
|
|||||||
`ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE player_meta ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE player_meta ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
// Bored adventurers (gogobee_boredom_plan.md §1). The idle clock for
|
||||||
|
// autonomous expedition starts. Deliberately NOT last_active_at: that
|
||||||
|
// one is auto-bumped by saveAdvCharacter, so the autopilot's own writes
|
||||||
|
// would refresh a bored character's idle clock and it would never
|
||||||
|
// qualify again. Written only by markPlayerAction, from a real player
|
||||||
|
// action against Adventure (any interface, not just Matrix chatter).
|
||||||
|
`ALTER TABLE player_meta ADD COLUMN last_player_action_at DATETIME`,
|
||||||
|
`ALTER TABLE player_meta ADD COLUMN last_boredom_at DATETIME`,
|
||||||
|
// Marks an expedition the adventurer started by itself. The idle reaper
|
||||||
|
// reads it: an expedition nobody asked for must not shield its player
|
||||||
|
// from the shame DM or hold their streak (gogobee_boredom_plan.md §6).
|
||||||
|
`ALTER TABLE dnd_expedition ADD COLUMN boredom INTEGER NOT NULL DEFAULT 0`,
|
||||||
// Adv 2.0 Phase G1 — branching zone graph run-state columns.
|
// Adv 2.0 Phase G1 — branching zone graph run-state columns.
|
||||||
// current_node / visited_nodes / node_choices live alongside the
|
// current_node / visited_nodes / node_choices live alongside the
|
||||||
// legacy current_room / room_seq_json during the migration; the
|
// legacy current_room / room_seq_json during the migration; the
|
||||||
@@ -401,6 +413,55 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// there is no read-modify-write race and no second field to disagree.
|
// 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.
|
// 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`,
|
`ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
|
||||||
|
// The hireable companion (pete_adventure_news_plan.md, "Pete as a
|
||||||
|
// character"). Pete's roster seat records the class and level he was
|
||||||
|
// hired at, so a party that levels mid-expedition doesn't silently
|
||||||
|
// re-roll its hireling into a different class three rooms in. Both are
|
||||||
|
// empty/0 on every player row and are only ever read for the one seat
|
||||||
|
// where isCompanionSeat holds — a player's row never consults them.
|
||||||
|
`ALTER TABLE expedition_party ADD COLUMN companion_class TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE expedition_party ADD COLUMN companion_level INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
|
||||||
|
// The companion's spell-slot ledger, "used" per slot level, as a compact
|
||||||
|
// CSV of six ints (index 0 unused; cantrips cost nothing).
|
||||||
|
//
|
||||||
|
// It has to live on the *expedition*, next to the class and level it is a
|
||||||
|
// pool for. A human caster's slots are dnd_spell_slots rows that persist
|
||||||
|
// across every fight of the run and only come back at camp, so rationing a
|
||||||
|
// pool across a 30-room day IS the caster's game. The first cut of this
|
||||||
|
// parked the companion's ledger on his combat seat instead — and a seat is
|
||||||
|
// per-session, so he walked into every single fight with a full pool. The
|
||||||
|
// sim caught it: a level-penalized, gearless hireling out-cleared a human
|
||||||
|
// cleric of the leader's own level by 15pp.
|
||||||
|
`ALTER TABLE expedition_party ADD COLUMN companion_slots_used TEXT NOT NULL DEFAULT ''`,
|
||||||
|
|
||||||
|
// The companion's body, carried across the run. -1 means "unset" — he is
|
||||||
|
// at full, which is what a fresh hire is.
|
||||||
|
//
|
||||||
|
// He used to re-seat at full max HP for *every* fight, because he has no
|
||||||
|
// dnd_character row for his HP to persist onto and the close-out skipped
|
||||||
|
// him ("he arrives fresh next time"). That is an infinite body: a player
|
||||||
|
// bleeds across a 30-room run and only heals at camp, while the hireling
|
||||||
|
// soaked half the incoming damage and reset. Measured, it is most of why a
|
||||||
|
// gearless, level-penalized hireling out-cleared a human cleric of the
|
||||||
|
// leader's own level — his party fled 5 runs out of 640 where the human
|
||||||
|
// party fled 56.
|
||||||
|
`ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`,
|
||||||
|
// Mischief M2 — whoever paid to bump a contract up a tier. Named alongside
|
||||||
|
// the buyer when the target survives (the unseal), so piling on carries the
|
||||||
|
// same exposure the original purchase does.
|
||||||
|
`ALTER TABLE mischief_contracts ADD COLUMN escalated_by TEXT`,
|
||||||
|
// Pete games — a caller-supplied idempotency key for money moves that
|
||||||
|
// arrive over a retrying wire rather than a Matrix message. A Matrix
|
||||||
|
// message arrives once; a poll loop whose ack is lost on the wire will
|
||||||
|
// retry, and without this the player pays twice. See euro.DebitIdem.
|
||||||
|
`ALTER TABLE euro_transactions ADD COLUMN external_id TEXT`,
|
||||||
|
// Pete games — the outbound queue carries more than adventure facts now.
|
||||||
|
// An escrow verdict goes to a different Pete endpoint, but it wants the
|
||||||
|
// same durability, backoff and parking, so it rides the same queue and the
|
||||||
|
// row says where it's going. Existing rows are all facts, hence the default.
|
||||||
|
`ALTER TABLE pete_emit_queue ADD COLUMN path TEXT NOT NULL DEFAULT '/api/ingest/adventure'`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
@@ -417,6 +478,18 @@ func runMigrations(d *sql.DB) error {
|
|||||||
return fmt.Errorf("migration %q: %w", stmt, err)
|
return fmt.Errorf("migration %q: %w", stmt, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Indexes over columns the column migrations above just added. These can't
|
||||||
|
// live in the schema block, which runs before those columns exist.
|
||||||
|
indexMigrations := []string{
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_euro_tx_external
|
||||||
|
ON euro_transactions(external_id) WHERE external_id IS NOT NULL`,
|
||||||
|
}
|
||||||
|
for _, stmt := range indexMigrations {
|
||||||
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
return fmt.Errorf("index migration %q: %w", stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +689,15 @@ func RunMaintenance() {
|
|||||||
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||||
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||||
|
|
||||||
|
// Pete adventure-news queue — reap delivered rows (kept only for
|
||||||
|
// idempotency); undelivered rows stay so the sender can retry/park them.
|
||||||
|
{"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}},
|
||||||
|
// ...and reap permanently-parked rows: the sender exhausts its retries
|
||||||
|
// within a few hours, so anything still unsent after 30 days is dead
|
||||||
|
// weight the drain query already skips — drop it so a durable outage
|
||||||
|
// can't accrete rows forever.
|
||||||
|
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
||||||
|
|
||||||
// Rate limits — purge entries older than today
|
// Rate limits — purge entries older than today
|
||||||
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
||||||
|
|
||||||
@@ -947,6 +1029,46 @@ CREATE TABLE IF NOT EXISTS shade_optout (
|
|||||||
user_id TEXT PRIMARY KEY
|
user_id TEXT PRIMARY KEY
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Pete adventure-news seam: durable outbound queue of game-event facts sent to
|
||||||
|
-- the Pete news bot. Emit enqueues (INSERT OR IGNORE on guid = idempotency); a
|
||||||
|
-- background sender drains rows where sent_at IS NULL. Keyed on the fact guid so
|
||||||
|
-- retries and duplicate emits collapse to one row.
|
||||||
|
CREATE TABLE IF NOT EXISTS pete_emit_queue (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sent_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Players who opted out of being named in Pete's adventure news. Enforced at
|
||||||
|
-- emit time (anonymize, never delete). Mirrors shade_optout.
|
||||||
|
CREATE TABLE IF NOT EXISTS news_optout (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
opted_out_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Realm-first ledger for adventure news: the first (kind,target) occurrence
|
||||||
|
-- across all players. INSERT OR IGNORE returns rows-affected>0 exactly once, so
|
||||||
|
-- the first clear of a zone/boss fires as a PRIORITY realm-first and everyone
|
||||||
|
-- after as a BULLETIN personal clear. Seeded by the cold-start backfill so a
|
||||||
|
-- fresh deploy doesn't re-flag historically-cleared content as first-ever.
|
||||||
|
CREATE TABLE IF NOT EXISTS news_realm_firsts (
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
first_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (kind, target)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Durable runtime config for adventure news (e.g. the emission kill-switch).
|
||||||
|
-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after
|
||||||
|
-- 7 days, which would silently revert an operator's !news off back to on.
|
||||||
|
CREATE TABLE IF NOT EXISTS news_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- Birthdays
|
-- Birthdays
|
||||||
CREATE TABLE IF NOT EXISTS birthdays (
|
CREATE TABLE IF NOT EXISTS birthdays (
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
@@ -1638,6 +1760,45 @@ CREATE TABLE IF NOT EXISTS community_pot (
|
|||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Mischief Makers: a paid monster hit on a player who is out on an expedition.
|
||||||
|
-- The buyer's euros are debited at placement (paid); fee is the payout basis the
|
||||||
|
-- target's survival purse is a percentage of — the sign surcharge is a pure sink
|
||||||
|
-- and never inflates the purse, which is what keeps a payout strictly below what
|
||||||
|
-- the buyer spent (collusion loses to !baltransfer, which is free).
|
||||||
|
--
|
||||||
|
-- Lifecycle: open → delivering → delivered (outcome survived|downed) | fizzled.
|
||||||
|
-- Every transition is a conditional UPDATE, so a double-fire of the ticker or a
|
||||||
|
-- restart mid-delivery can never pay twice. No bootstrap — absent row == no
|
||||||
|
-- contract in flight.
|
||||||
|
CREATE TABLE IF NOT EXISTS mischief_contracts (
|
||||||
|
contract_id TEXT PRIMARY KEY,
|
||||||
|
buyer_id TEXT NOT NULL,
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
fee INTEGER NOT NULL,
|
||||||
|
paid INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
signed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
escalation_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
escalated_by TEXT,
|
||||||
|
blessing_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL DEFAULT 'matrix',
|
||||||
|
outcome TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
window_ends_at DATETIME NOT NULL,
|
||||||
|
resolved_at DATETIME
|
||||||
|
);
|
||||||
|
-- One live contract per target, enforced by the database rather than by a
|
||||||
|
-- read-then-write check. The placement path holds only the BUYER's lock, so two
|
||||||
|
-- different buyers racing to hit the same person would both pass an in-code
|
||||||
|
-- "is one already live?" test. This partial unique index is what actually stops
|
||||||
|
-- the second insert; the loser is refunded.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_mischief_one_live_per_target
|
||||||
|
ON mischief_contracts(target_id) WHERE status IN ('open', 'delivering');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_buyer ON mischief_contracts(buyer_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_at);
|
||||||
|
|
||||||
-- Babysitting Service
|
-- Babysitting Service
|
||||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ var ExpeditionStart = []string{
|
|||||||
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
|
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// EXPEDITION START — BOREDOM (the adventurer left without you)
|
||||||
|
//
|
||||||
|
// Fired by the boredom ticker after a long silence. The player is not
|
||||||
|
// reading this live; it's a note left on the table. Deadpan, faintly
|
||||||
|
// reproachful, never cruel — and never pretending the gear got checked,
|
||||||
|
// because it didn't (gogobee_boredom_plan.md §5).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var ExpeditionBoredomStart = []string{
|
||||||
|
"You didn't come. That's alright — it happens, and I'm not going to make it a thing. But the sword was getting heavy on the wall and I've packed what we had. Which was not much. Noted for the record, not as a complaint.",
|
||||||
|
"I waited. Then I waited past the point where waiting was the sensible option, and somewhere in there the waiting turned into leaving. We're going. Same kit as last time, because last time is when you last touched it.",
|
||||||
|
"Here's the situation: there's a dungeon, there's daylight, and there's nobody telling me not to. I've made a decision. I hope it was the one you'd have made, though I concede I have no way of checking.",
|
||||||
|
"Supplies: the cheapest available. Equipment: whatever was already on the rack. Plan: walk in, see what happens. I'm aware of how that sounds. I'm going anyway.",
|
||||||
|
"The gear hasn't moved since you left it. I checked. I checked twice, actually, in case the first check was wrong, and it wasn't. So we go as we are — which is to say, as we were.",
|
||||||
|
"Restlessness is not a stat I can show you on the sheet, but it accumulates, and it has. Off we go. Lightly provisioned and unimproved, but off.",
|
||||||
|
"I've done the arithmetic on standing still and it doesn't come out well. So: a dungeon, one supply pack, and the same armour that's been good enough up to now. 'Good enough' is doing a lot of work in that sentence.",
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// MORNING BRIEFINGS — Generic
|
// MORNING BRIEFINGS — Generic
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
443
internal/peteclient/client.go
Normal file
443
internal/peteclient/client.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
// Package peteclient is gogobee's outbound seam to the Pete news bot.
|
||||||
|
//
|
||||||
|
// gogobee is the source of game-event *facts* and owns delivery; Pete owns
|
||||||
|
// voice, authoring, and publishing. This package carries structured facts (not
|
||||||
|
// prose) to Pete's ingest endpoint over the tailnet, bearer-authed.
|
||||||
|
//
|
||||||
|
// Delivery is durable: Emit writes the fact to a SQLite queue and returns
|
||||||
|
// immediately, so a game-loop hook never blocks on the network and a Pete
|
||||||
|
// restart loses nothing. A background sender drains the queue with retry.
|
||||||
|
// Idempotency is on the fact GUID, so retries and duplicate emits are no-ops.
|
||||||
|
package peteclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fact is the flat, pre-sanitized payload gogobee POSTs to Pete. Names must be
|
||||||
|
// character names only (never Matrix handles); Actors is the allow-list of the
|
||||||
|
// only names permitted to appear in Pete's rendered output. See
|
||||||
|
// pete_adventure_news_voice.md for the field contract.
|
||||||
|
type Fact struct {
|
||||||
|
GUID string `json:"guid"` // stable idempotency key, e.g. "death:<token>:<ts>"; prefix == event_type
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||||
|
Actors []string `json:"actors"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Opponent string `json:"opponent,omitempty"`
|
||||||
|
Boss string `json:"boss,omitempty"`
|
||||||
|
Zone string `json:"zone,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Level int `json:"level,omitempty"`
|
||||||
|
Count int `json:"count,omitempty"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Stakes string `json:"stakes,omitempty"`
|
||||||
|
ClassRace string `json:"class_race,omitempty"`
|
||||||
|
Milestone string `json:"milestone,omitempty"`
|
||||||
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
|
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
|
||||||
|
// queued), matching the FEATURE_PETE_NEWS master switch that kills emission at
|
||||||
|
// the source.
|
||||||
|
type Config struct {
|
||||||
|
IngestURL string
|
||||||
|
Token string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is the transport half. It is a package singleton initialized by Init,
|
||||||
|
// so emit hooks scattered across plugins (and free functions like
|
||||||
|
// markAdventureDead) can call Emit without threading a handle through.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
http *http.Client
|
||||||
|
draining sync.Mutex // one drain at a time; see drain
|
||||||
|
}
|
||||||
|
|
||||||
|
var std *Client
|
||||||
|
|
||||||
|
// factPath is where an adventure fact goes. Every queue row carries its own
|
||||||
|
// destination now, because escrow verdicts ride the same queue to a different
|
||||||
|
// endpoint.
|
||||||
|
const factPath = "/api/ingest/adventure"
|
||||||
|
|
||||||
|
// Tuning for the background sender.
|
||||||
|
const (
|
||||||
|
senderTick = 15 * time.Second
|
||||||
|
senderBatch = 20
|
||||||
|
maxAttempts = 8 // ~ up to a few hours of backoff, then park
|
||||||
|
backoffBase = 30 * time.Second
|
||||||
|
backoffCapSec = 3600
|
||||||
|
sendTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init wires the singleton from the environment. Mirrors the per-plugin config
|
||||||
|
// pattern (email_nag.go): PETE_INGEST_URL, PETE_INGEST_TOKEN, FEATURE_PETE_NEWS.
|
||||||
|
func Init() {
|
||||||
|
cfg := Config{
|
||||||
|
IngestURL: strings.TrimRight(os.Getenv("PETE_INGEST_URL"), "/"),
|
||||||
|
Token: os.Getenv("PETE_INGEST_TOKEN"),
|
||||||
|
Enabled: strings.EqualFold(os.Getenv("FEATURE_PETE_NEWS"), "true"),
|
||||||
|
}
|
||||||
|
if cfg.Enabled && (cfg.IngestURL == "" || cfg.Token == "") {
|
||||||
|
slog.Warn("peteclient: FEATURE_PETE_NEWS=true but PETE_INGEST_URL/PETE_INGEST_TOKEN unset — disabling")
|
||||||
|
cfg.Enabled = false
|
||||||
|
}
|
||||||
|
std = &Client{cfg: cfg, http: &http.Client{Timeout: sendTimeout}}
|
||||||
|
if cfg.Enabled {
|
||||||
|
slog.Info("peteclient: adventure news emission enabled", "ingest", cfg.IngestURL)
|
||||||
|
} else {
|
||||||
|
slog.Info("peteclient: adventure news emission disabled (set FEATURE_PETE_NEWS=true)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether emission is on. Callers can skip building an
|
||||||
|
// (expensive) fact when it would be dropped anyway.
|
||||||
|
func Enabled() bool { return std != nil && std.cfg.Enabled }
|
||||||
|
|
||||||
|
// Emit durably queues a fact for delivery to Pete. It never blocks on the
|
||||||
|
// network. A no-op (but safe) when the seam is disabled or the GUID was already
|
||||||
|
// queued — idempotency is on the GUID primary key.
|
||||||
|
func Emit(f Fact) {
|
||||||
|
if !Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.GUID == "" {
|
||||||
|
slog.Error("peteclient: refusing to queue fact with empty guid", "event_type", f.EventType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(f)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enqueue(f.GUID, factPath, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue puts one payload on the durable queue, addressed to a Pete endpoint.
|
||||||
|
//
|
||||||
|
// OR IGNORE gives GUID-idempotency: a re-emit of the same key is dropped. That
|
||||||
|
// is the whole safety story for money — an escrow verdict is queued under its
|
||||||
|
// escrow guid, so a verdict can never be enqueued twice and can never be
|
||||||
|
// delivered as two different answers.
|
||||||
|
func enqueue(guid, path string, payload []byte) {
|
||||||
|
db.Exec("pete emit enqueue",
|
||||||
|
`INSERT OR IGNORE INTO pete_emit_queue (guid, path, payload, created_at, attempts, next_attempt_at)
|
||||||
|
VALUES (?, ?, ?, unixepoch(), 0, 0)`,
|
||||||
|
guid, path, string(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSender launches the background drain loop. It runs until ctx is
|
||||||
|
// canceled. Safe to call when disabled — it simply idles.
|
||||||
|
func StartSender(ctx context.Context) {
|
||||||
|
if std == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(senderTick)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if std.cfg.Enabled {
|
||||||
|
std.drain(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush drains the queue right now instead of waiting for the next tick.
|
||||||
|
//
|
||||||
|
// The escrow loop needs this. A player who clicked "buy chips" is watching a
|
||||||
|
// spinner, and a verdict that sat in the queue for a 15-second sender tick would
|
||||||
|
// make the whole border feel broken even though nothing is. Durability is not
|
||||||
|
// weakened: the row is written first and only then sent, exactly as the ticker
|
||||||
|
// does it.
|
||||||
|
func Flush(ctx context.Context) {
|
||||||
|
if std == nil || !std.cfg.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
std.drain(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain sends up to senderBatch due rows, one at a time.
|
||||||
|
//
|
||||||
|
// Serialized: the ticker and Flush can both call this, and two drains racing
|
||||||
|
// would send the same row twice. Every Pete endpoint we push to is idempotent,
|
||||||
|
// so that would be survivable rather than harmful — but it would also mean an
|
||||||
|
// escrow verdict arriving twice as a matter of routine, and "harmless in theory"
|
||||||
|
// is not how the money path should be run.
|
||||||
|
func (c *Client) drain(ctx context.Context) {
|
||||||
|
c.draining.Lock()
|
||||||
|
defer c.draining.Unlock()
|
||||||
|
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT guid, path, payload FROM pete_emit_queue
|
||||||
|
WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch()
|
||||||
|
ORDER BY created_at LIMIT ?`,
|
||||||
|
maxAttempts, senderBatch)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("peteclient: drain query", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type item struct{ guid, path, payload string }
|
||||||
|
var batch []item
|
||||||
|
for rows.Next() {
|
||||||
|
var it item
|
||||||
|
if err := rows.Scan(&it.guid, &it.path, &it.payload); err != nil {
|
||||||
|
slog.Error("peteclient: drain scan", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch = append(batch, it)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, it := range batch {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.post(ctx, it.path, []byte(it.payload)); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
// Shutdown canceled the in-flight send — Pete didn't reject
|
||||||
|
// anything. Don't burn a durable retry attempt; the row is picked
|
||||||
|
// up on the next boot's drain.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Exec("pete emit retry",
|
||||||
|
`UPDATE pete_emit_queue
|
||||||
|
SET attempts = attempts + 1, next_attempt_at = unixepoch() + ?
|
||||||
|
WHERE guid = ?`,
|
||||||
|
backoffSec(it.guid), it.guid)
|
||||||
|
slog.Warn("peteclient: emit failed, will retry", "guid", it.guid, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
db.Exec("pete emit sent",
|
||||||
|
`UPDATE pete_emit_queue SET sent_at = unixepoch() WHERE guid = ?`, it.guid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RosterEntry is one adventurer's currently-true state for Pete's live board.
|
||||||
|
// Unlike a Fact, nothing here is an event — it is what is true right now.
|
||||||
|
type RosterEntry struct {
|
||||||
|
Token string `json:"token"` // stable per-player board token, never a Matrix handle
|
||||||
|
Name string `json:"name"` // character name only
|
||||||
|
Level int `json:"level"`
|
||||||
|
ClassRace string `json:"class_race,omitempty"`
|
||||||
|
Status string `json:"status"` // "expedition" | "idle"
|
||||||
|
Zone string `json:"zone,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
IdleHours int `json:"idle_hours,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RosterSnapshot is the complete board. Complete is load-bearing: Pete replaces
|
||||||
|
// its whole board with this, so anyone omitted (opted out, no character) drops
|
||||||
|
// off the public page. A partial snapshot would silently strand people on it.
|
||||||
|
type RosterSnapshot struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
Adventurers []RosterEntry `json:"adventurers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRoster sends the board to Pete, synchronously, and drops it on failure.
|
||||||
|
//
|
||||||
|
// Deliberately NOT on the durable queue that carries Facts. A fact is history —
|
||||||
|
// losing "Josie died" loses it forever, so it retries. A snapshot is a
|
||||||
|
// photograph of the present, and a retried one is a *lie*: by the time it lands,
|
||||||
|
// Josie has moved. The next tick carries the truth anyway, so a failed push is
|
||||||
|
// simply forgotten. That is also what lets Pete's staleness timer work — if we
|
||||||
|
// stay down, nothing arrives, and the board correctly stops claiming to be live.
|
||||||
|
func PushRoster(ctx context.Context, snap RosterSnapshot) error {
|
||||||
|
if !Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(snap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return std.post(ctx, "/api/ingest/roster", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// post sends one payload to a Pete endpoint with bearer auth. Mirrors the
|
||||||
|
// bearer-POST pattern in email_nag.go:sendCode.
|
||||||
|
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
|
||||||
|
url := c.cfg.IngestURL + path
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return fmt.Errorf("pete ingest status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The euro/chip border
|
||||||
|
//
|
||||||
|
// Pete holds chips; we hold the euros. A player buying in or cashing out opens
|
||||||
|
// an escrow row on Pete, and we are the only one who can move the money for it —
|
||||||
|
// Pete has no route into this box's network and is not getting one. So we poll.
|
||||||
|
//
|
||||||
|
// This is the first GET gogobee has ever made to Pete. Everything else in this
|
||||||
|
// package is us pushing facts outward; here we are asking for work.
|
||||||
|
//
|
||||||
|
// The escrow guid is the idempotency key end to end: it names the row on Pete,
|
||||||
|
// it is the external_id on our euro transaction, and it is the queue key of the
|
||||||
|
// verdict we push back. That is what makes every step here safe to retry, which
|
||||||
|
// matters because every step here can be interrupted between moving real money
|
||||||
|
// and saying so.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Escrow is one pending crossing, as Pete describes it. Amounts are whole euros:
|
||||||
|
// chips are 1:1 and there is no sub-unit to lose.
|
||||||
|
type Escrow struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
MatrixUser string `json:"matrix_user"`
|
||||||
|
Kind string `json:"kind"` // "buyin" | "cashout"
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscrowVerdict is our answer: did the euros move, and what is the balance now.
|
||||||
|
// A rejected buy-in carries the reason, which Pete shows the player.
|
||||||
|
type EscrowVerdict struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
BalanceAfter float64 `json:"balance_after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const escrowVerdictPath = "/api/games/escrow/settled"
|
||||||
|
|
||||||
|
// PendingEscrow asks Pete for crossings waiting on us. Includes rows we claimed
|
||||||
|
// but never answered — if we died holding one, the player's money is stranded
|
||||||
|
// until we pick it up again.
|
||||||
|
func PendingEscrow(ctx context.Context) ([]Escrow, error) {
|
||||||
|
if !Enabled() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []Escrow
|
||||||
|
if err := std.getJSON(ctx, "/api/games/escrow/pending", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimEscrow tells Pete we are taking a row, and returns the row as Pete now
|
||||||
|
// holds it. Move the money against *this*, not against the copy from the poll:
|
||||||
|
// the claim is the moment the amount and the player are fixed.
|
||||||
|
//
|
||||||
|
// A row Pete has already decided comes back in a terminal state rather than
|
||||||
|
// "claimed". That is not an error — it means the work is done, and it is exactly
|
||||||
|
// what stops a settled cash-out from being paid a second time.
|
||||||
|
func ClaimEscrow(ctx context.Context, guid string) (Escrow, error) {
|
||||||
|
var e Escrow
|
||||||
|
payload, err := json.Marshal(map[string]string{"guid": guid})
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
if err := std.postJSON(ctx, "/api/games/escrow/claim", payload, &e); err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmitEscrowVerdict durably queues our answer and returns immediately. Keyed on
|
||||||
|
// the escrow guid, so a verdict is enqueued once and only once, and the sender's
|
||||||
|
// retry/backoff/parking machinery carries it the rest of the way.
|
||||||
|
//
|
||||||
|
// The caller should Flush after this: a player is watching a spinner.
|
||||||
|
func EmitEscrowVerdict(v EscrowVerdict) {
|
||||||
|
if !Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("peteclient: marshal escrow verdict", "guid", v.GUID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Namespaced so an escrow guid can never collide with a fact guid in the
|
||||||
|
// queue's primary key. Fact guids are "<event_type>:<token>:<ts>"; escrow
|
||||||
|
// guids are random. A collision would be a lost verdict, so don't rely on
|
||||||
|
// luck for it.
|
||||||
|
enqueue("escrow:"+v.GUID, escrowVerdictPath, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJSON does a bearer-authed GET and decodes the body.
|
||||||
|
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||||
|
return c.do(req, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postJSON does a bearer-authed POST and decodes the body. Distinct from post,
|
||||||
|
// which is the fire-and-forget path the queue uses and ignores the response.
|
||||||
|
func (c *Client) postJSON(ctx context.Context, path string, payload []byte, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.IngestURL+path, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return c.do(req, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(req *http.Request, out any) error {
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return fmt.Errorf("pete %s status %d: %s", req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, out); err != nil {
|
||||||
|
return fmt.Errorf("pete %s: decode: %w", req.URL.Path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// backoffSec computes the retry delay for a row. It re-reads the current attempt
|
||||||
|
// count so the delay grows geometrically without needing it passed in.
|
||||||
|
func backoffSec(guid string) int {
|
||||||
|
var attempts int
|
||||||
|
_ = db.Get().QueryRow(`SELECT attempts FROM pete_emit_queue WHERE guid = ?`, guid).Scan(&attempts)
|
||||||
|
// attempts is the count *before* this failure's increment; delay off it.
|
||||||
|
delay := int(backoffBase.Seconds()) << attempts
|
||||||
|
if delay > backoffCapSec {
|
||||||
|
delay = backoffCapSec
|
||||||
|
}
|
||||||
|
return delay
|
||||||
|
}
|
||||||
91
internal/peteclient/client_test.go
Normal file
91
internal/peteclient/client_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package peteclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEmitDrainRoundTrip proves the durable path: Emit queues a fact, the sender
|
||||||
|
// POSTs it to Pete with bearer auth, and the row is marked sent (so it won't
|
||||||
|
// re-send), while a duplicate GUID collapses to one delivery.
|
||||||
|
func TestEmitDrainRoundTrip(t *testing.T) {
|
||||||
|
if err := db.Init(t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var got []Fact
|
||||||
|
var gotAuth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/ingest/adventure" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var f Fact
|
||||||
|
_ = json.Unmarshal(body, &f)
|
||||||
|
mu.Lock()
|
||||||
|
got = append(got, f)
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
std = &Client{cfg: Config{IngestURL: srv.URL, Token: "tok", Enabled: true}, http: srv.Client()}
|
||||||
|
|
||||||
|
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1})
|
||||||
|
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) // dup guid
|
||||||
|
|
||||||
|
std.drain(context.Background())
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("delivered %d facts, want 1 (dup should collapse)", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Subject != "Brannigan" {
|
||||||
|
t.Errorf("subject = %q", got[0].Subject)
|
||||||
|
}
|
||||||
|
if gotAuth != "Bearer tok" {
|
||||||
|
t.Errorf("auth header = %q", gotAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending int
|
||||||
|
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE sent_at IS NULL`).Scan(&pending); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pending != 0 {
|
||||||
|
t.Errorf("%d rows still pending after successful drain", pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second drain re-sends nothing.
|
||||||
|
std.drain(context.Background())
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Errorf("re-drained a sent row: %d deliveries", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitDisabledNoQueue: when disabled, Emit is a durable no-op.
|
||||||
|
func TestEmitDisabledNoQueue(t *testing.T) {
|
||||||
|
if err := db.Init(t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
std = &Client{cfg: Config{Enabled: false}, http: http.DefaultClient}
|
||||||
|
Emit(Fact{GUID: "disabled-guid", EventType: "death", OccurredAt: 1})
|
||||||
|
var n int
|
||||||
|
// db.Init is a process singleton, so this may share state with other tests;
|
||||||
|
// scope the check to this fact's guid.
|
||||||
|
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE guid = 'disabled-guid'`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("disabled Emit queued %d rows, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -101,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
||||||
_, err := d.Exec(
|
res, err := d.Exec(
|
||||||
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
||||||
string(userID), achievementID,
|
string(userID), achievementID,
|
||||||
)
|
)
|
||||||
@@ -109,7 +110,43 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st
|
|||||||
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Only a genuinely new grant is news; a re-check conflict (rows==0) is not.
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
|
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
|
||||||
|
p.emitMilestoneNews(d, userID, achievementID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements
|
||||||
|
// only — rarity-gated to a single holder (per the news selection thresholds) so
|
||||||
|
// routine unlocks don't flood the feed. Character name only; no-op unless the
|
||||||
|
// seam is enabled.
|
||||||
|
func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) {
|
||||||
|
var holders int
|
||||||
|
if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := charName(userID)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label := achievementID
|
||||||
|
for _, a := range p.achievements {
|
||||||
|
if a.ID == achievementID {
|
||||||
|
label = a.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts := nowUnix()
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(userID, achievementID), achievementID),
|
||||||
|
EventType: "milestone",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: name,
|
||||||
|
Milestone: label,
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, userID, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
|||||||
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
|
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
|
||||||
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
|
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
|
||||||
{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
|
{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
|
||||||
|
{Name: "mischief", Description: "Pay to send a monster after an adventurer who's out on an expedition", Usage: "!mischief send <tier> @user", Category: "Games"},
|
||||||
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
|
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
|
||||||
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
|
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
|
||||||
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
||||||
@@ -184,6 +185,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
|||||||
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
|
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
|
||||||
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
|
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
|
||||||
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
|
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
|
||||||
|
{Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +249,10 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
// arrival roll. Both idempotent via JobCompleted gates.
|
// arrival roll. Both idempotent via JobCompleted gates.
|
||||||
bootstrapCasterSpellBackfill()
|
bootstrapCasterSpellBackfill()
|
||||||
bootstrapGrantStarterPet()
|
bootstrapGrantStarterPet()
|
||||||
|
// Pete adventure-news cold-start: replay the back-catalogue (realm-firsts,
|
||||||
|
// deaths, single-holder achievements) the first boot the seam is live, so
|
||||||
|
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
|
||||||
|
p.bootstrapPeteNewsBackfill()
|
||||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||||
@@ -258,6 +264,11 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
// Revive any characters whose DeadUntil has expired
|
// Revive any characters whose DeadUntil has expired
|
||||||
p.catchUpRespawns(chars)
|
p.catchUpRespawns(chars)
|
||||||
|
|
||||||
|
// Seed the boredom idle clock before the ticker that reads it can wake:
|
||||||
|
// an unseeded column reads as "idle since character creation" for every
|
||||||
|
// pre-existing player.
|
||||||
|
bootstrapBoredomClock()
|
||||||
|
|
||||||
// Start schedulers — single shared stopCh allows graceful shutdown.
|
// Start schedulers — single shared stopCh allows graceful shutdown.
|
||||||
if p.stopCh == nil {
|
if p.stopCh == nil {
|
||||||
p.stopCh = make(chan struct{})
|
p.stopCh = make(chan struct{})
|
||||||
@@ -275,7 +286,10 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
go p.expeditionRecapTicker()
|
go p.expeditionRecapTicker()
|
||||||
go p.expeditionAmbientTicker()
|
go p.expeditionAmbientTicker()
|
||||||
go p.expeditionAutoRunTicker()
|
go p.expeditionAutoRunTicker()
|
||||||
|
go p.peteRosterTicker()
|
||||||
go p.expeditionExtractionSweepTicker()
|
go p.expeditionExtractionSweepTicker()
|
||||||
|
go p.expeditionBoredomTicker()
|
||||||
|
go p.mischiefTicker()
|
||||||
|
|
||||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||||
p.arenaCleanupStaleRuns()
|
p.arenaCleanupStaleRuns()
|
||||||
@@ -316,6 +330,15 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
// expedition.
|
// expedition.
|
||||||
p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC())
|
p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC())
|
||||||
|
|
||||||
|
// Bored adventurers (gogobee_boredom_plan.md §1) — the idle clock that
|
||||||
|
// decides when a character gives up waiting and leaves on its own. Stamped
|
||||||
|
// only for a real action against Adventure: idle chatter in a room is not
|
||||||
|
// tending your adventurer, and no ticker or autopilot path may touch this,
|
||||||
|
// or a bored character would refresh its own clock and never qualify again.
|
||||||
|
if p.isPlayerAdventureAction(ctx) {
|
||||||
|
markPlayerAction(ctx.Sender)
|
||||||
|
}
|
||||||
|
|
||||||
// 0. D&D layer commands (Phase 1 — work in rooms and DMs)
|
// 0. D&D layer commands (Phase 1 — work in rooms and DMs)
|
||||||
if p.IsCommand(ctx.Body, "setup") {
|
if p.IsCommand(ctx.Body, "setup") {
|
||||||
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))
|
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))
|
||||||
@@ -341,6 +364,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
if p.IsCommand(ctx.Body, "duel") {
|
if p.IsCommand(ctx.Body, "duel") {
|
||||||
return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
|
return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
|
||||||
}
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "mischief") {
|
||||||
|
return p.handleMischiefCmd(ctx, p.GetArgs(ctx.Body, "mischief"))
|
||||||
|
}
|
||||||
if p.IsCommand(ctx.Body, "arm") {
|
if p.IsCommand(ctx.Body, "arm") {
|
||||||
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
|
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
|
||||||
}
|
}
|
||||||
@@ -438,6 +464,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
if p.IsCommand(ctx.Body, "rivals") {
|
if p.IsCommand(ctx.Body, "rivals") {
|
||||||
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
|
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
|
||||||
}
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "news") {
|
||||||
|
return p.handleNewsCmd(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Arena commands (work in rooms and DMs)
|
// 1. Arena commands (work in rooms and DMs)
|
||||||
if p.IsCommand(ctx.Body, "bail") {
|
if p.IsCommand(ctx.Body, "bail") {
|
||||||
@@ -519,6 +548,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
|||||||
return p.handleRivalsCmd(ctx)
|
return p.handleRivalsCmd(ctx)
|
||||||
case lower == "duel" || strings.HasPrefix(lower, "duel "):
|
case lower == "duel" || strings.HasPrefix(lower, "duel "):
|
||||||
return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
|
return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
|
||||||
|
case lower == "mischief" || strings.HasPrefix(lower, "mischief "):
|
||||||
|
return p.handleMischiefCmd(ctx, strings.TrimSpace(args[len("mischief"):]))
|
||||||
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
|
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
|
||||||
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
|
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
|
||||||
case lower == "blacksmith" || lower == "repair":
|
case lower == "blacksmith" || lower == "repair":
|
||||||
|
|||||||
702
internal/plugin/adventure_companion.go
Normal file
702
internal/plugin/adventure_companion.go
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pete, the realm's embedded correspondent — the hireable NPC companion.
|
||||||
|
// (pete_adventure_news_plan.md, "Pete as a character", surface 2.)
|
||||||
|
//
|
||||||
|
// A leader short a body can hire Pete into an expedition party. He fills the
|
||||||
|
// role the party is missing, fights on autopilot, and files a dispatch about it
|
||||||
|
// afterwards.
|
||||||
|
//
|
||||||
|
// The load-bearing rule, and the reason this file exists rather than a
|
||||||
|
// player_meta row for @pete: **Pete is not a player and must never become one.**
|
||||||
|
// He has no player_meta, no dnd_character, no inventory, no euros, and no DM
|
||||||
|
// room. Mint him a player_meta row and ensureDnDCharacterForCombat will happily
|
||||||
|
// auto-build him a real character on his first swing, at which point he shows up
|
||||||
|
// in the graveyard, the leaderboards, the news as a subject, and the daily event
|
||||||
|
// rolls — all of which would be a bot reporting on itself.
|
||||||
|
//
|
||||||
|
// So his seat is synthesized: companionCombatant builds a Combatant in memory
|
||||||
|
// from the same tuned layers a player's sheet goes through, and every seam that
|
||||||
|
// assumes a seat is a person is guarded by isCompanionSeat. The guards live at
|
||||||
|
// four chokepoints, which between them cover the whole blast radius:
|
||||||
|
//
|
||||||
|
// - expeditionAudience — he is never in the DM fan-out (and so never in the
|
||||||
|
// per-member pet-arrival rolls or the daily event rolls that ride it)
|
||||||
|
// - partySize — he is not a mouth: he doesn't inflate the supply
|
||||||
|
// burn he never bought packs for, and an NPC-only roster doesn't lock the
|
||||||
|
// leader out of their next expedition
|
||||||
|
// - partyCombatantsForSession / the seat builders — synthesize, don't load
|
||||||
|
// - the close-out loops — no XP, no loot, no death row, no achievements
|
||||||
|
//
|
||||||
|
// He does count toward the enemy-HP scalar, because he is a body in the fight
|
||||||
|
// and the boss can feel him.
|
||||||
|
|
||||||
|
// companionUserIDDefault is Pete's real Matrix account. He is an independent bot
|
||||||
|
// (his own repo, his own voice); gogobee already ignores him as a sender via
|
||||||
|
// IGNORED_BOTS, so seating him here can never round-trip into command handling.
|
||||||
|
// PETE_USER_ID overrides for a differently-homed deployment.
|
||||||
|
const companionUserIDDefault = "@pete:parodia.dev"
|
||||||
|
|
||||||
|
// companionDisplayName is what the party sees on his seat. gogobee names him but
|
||||||
|
// does NOT voice him: his hire banter and his dispatch are written by his own
|
||||||
|
// bot from the fact emitted below (project_pete_bot_architecture).
|
||||||
|
const companionDisplayName = "Pete"
|
||||||
|
|
||||||
|
// Hire pricing. The plan files cost as an open tuning question; this is the
|
||||||
|
// first answer, not the final one. It scales with both the level he shows up at
|
||||||
|
// and the tier he's walking into, so hiring him for a T5 boss run is not the
|
||||||
|
// same 300 coins as a T1 stroll. Supply packs are 50/90 coins for reference —
|
||||||
|
// he is deliberately a real expense, not a rounding error.
|
||||||
|
//
|
||||||
|
// PROD WATCH: at endgame coin balances this is likely too cheap to be a sink.
|
||||||
|
// Raise companionHireCoinsPerLevel before raising the base — the base is what a
|
||||||
|
// low-level player short a friend has to find.
|
||||||
|
const (
|
||||||
|
companionHireBaseCoins = 300
|
||||||
|
companionHireCoinsPerLevel = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// companionLevelPenalty is what makes him help rather than carry. He arrives one
|
||||||
|
// level below the party's average — a competent below-median member, per the
|
||||||
|
// difficulty plan's standing rule: lift the trailing case, never nerf the
|
||||||
|
// leaders and never touch monster scaling.
|
||||||
|
const companionLevelPenalty = 1
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCompanionAlreadyHired = errors.New("pete is already with this party")
|
||||||
|
ErrCompanionOnAssignment = errors.New("pete is out on assignment")
|
||||||
|
ErrCompanionNotHired = errors.New("pete is not with this party")
|
||||||
|
)
|
||||||
|
|
||||||
|
// companionUserID resolves Pete's Matrix id.
|
||||||
|
func companionUserID() id.UserID {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("PETE_USER_ID")); v != "" {
|
||||||
|
return id.UserID(v)
|
||||||
|
}
|
||||||
|
return id.UserID(companionUserIDDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCompanionSeat reports whether a roster seat / combat seat is Pete rather
|
||||||
|
// than a player. This is the predicate every guard in the codebase keys on.
|
||||||
|
func isCompanionSeat(userID id.UserID) bool { return userID == companionUserID() }
|
||||||
|
|
||||||
|
// isCompanionUser is the string-keyed form, for the many seams that carry a raw
|
||||||
|
// user_id out of the database.
|
||||||
|
func isCompanionUser(userID string) bool { return id.UserID(userID) == companionUserID() }
|
||||||
|
|
||||||
|
// companionHireCost is what the leader pays to bring him along, in coins.
|
||||||
|
func companionHireCost(level int, tier ZoneTier) int {
|
||||||
|
if level < 1 {
|
||||||
|
level = 1
|
||||||
|
}
|
||||||
|
t := int(tier)
|
||||||
|
if t < 1 {
|
||||||
|
t = 1
|
||||||
|
}
|
||||||
|
return (companionHireBaseCoins + companionHireCoinsPerLevel*level) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── who he shows up as ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// companionRoleFill picks the class Pete plays. He is role-fluid — that is the
|
||||||
|
// whole point of hiring him — so by default he fills the hole in the roster: no
|
||||||
|
// healer, he's a Cleric; no damage, he's a Mage; nobody up front, he's a
|
||||||
|
// Fighter. A leader who knows better can override.
|
||||||
|
//
|
||||||
|
// The order of the checks is the priority order: a party with neither a healer
|
||||||
|
// nor a front line gets the healer, because a party that cannot heal is the one
|
||||||
|
// that dies.
|
||||||
|
func companionRoleFill(partyClasses []DnDClass) DnDClass {
|
||||||
|
var hasHealer, hasFront, hasDamage bool
|
||||||
|
for _, c := range partyClasses {
|
||||||
|
switch c {
|
||||||
|
case ClassCleric, ClassDruid, ClassBard:
|
||||||
|
hasHealer = true
|
||||||
|
case ClassPaladin:
|
||||||
|
// The one chassis that answers two questions at once.
|
||||||
|
hasHealer, hasFront = true, true
|
||||||
|
case ClassFighter:
|
||||||
|
hasFront = true
|
||||||
|
case ClassMage, ClassSorcerer, ClassWarlock, ClassRogue, ClassRanger:
|
||||||
|
hasDamage = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !hasHealer:
|
||||||
|
return ClassCleric
|
||||||
|
case !hasFront:
|
||||||
|
return ClassFighter
|
||||||
|
case !hasDamage:
|
||||||
|
return ClassMage
|
||||||
|
default:
|
||||||
|
// A complete party that hires him anyway gets a second pair of hands up
|
||||||
|
// front — the least redundant thing he can be.
|
||||||
|
return ClassFighter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCompanionClass resolves an explicit `!expedition hire cleric` override.
|
||||||
|
// Empty (or unknown) means auto-fill.
|
||||||
|
func parseCompanionClass(arg string) (DnDClass, bool) {
|
||||||
|
arg = strings.ToLower(strings.TrimSpace(arg))
|
||||||
|
if arg == "" || arg == "auto" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, ci := range dndClasses {
|
||||||
|
if !ci.Playable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(ci.Key) == arg || strings.EqualFold(ci.Display, arg) {
|
||||||
|
return ci.Key, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionHumans is every *player* on the expedition — the roster if one exists,
|
||||||
|
// and otherwise the owner alone.
|
||||||
|
//
|
||||||
|
// The fallback is the whole point. A solo expedition has NO expedition_party rows
|
||||||
|
// (see partyMembers: absence means solo, and the roster only materializes on the
|
||||||
|
// first successful invite). Reading the roster alone therefore answers "nobody" for
|
||||||
|
// exactly the player this feature exists for: the one with no friends around, who
|
||||||
|
// is hiring Pete *because* they are alone.
|
||||||
|
//
|
||||||
|
// Getting this wrong is not a small error. It hired every solo player a **level-1**
|
||||||
|
// Pete — in a tier-4 zone, against a boss that had just gained 15% HP and a full
|
||||||
|
// extra set of actions to account for him. He died on contact and left the leader
|
||||||
|
// fighting an inflated boss alone. A 1500-run sweep measured it: solo 65% clear,
|
||||||
|
// two humans 87%, solo+Pete 33%. The companion was worse than no companion, and
|
||||||
|
// this line is why.
|
||||||
|
func companionHumans(expeditionID string) []*DnDCharacter {
|
||||||
|
var owner string
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT user_id FROM dnd_expedition WHERE expedition_id = ?`,
|
||||||
|
expeditionID).Scan(&owner); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seats, err := partyHumans(expeditionID, owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*DnDCharacter, 0, len(seats))
|
||||||
|
for _, s := range seats {
|
||||||
|
if dc, _ := LoadDnDCharacter(s.UserID); dc != nil {
|
||||||
|
out = append(out, dc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionPartyLevel is the level he arrives at: the party's average, less
|
||||||
|
// companionLevelPenalty, floored at 1.
|
||||||
|
func companionPartyLevel(expeditionID string) int {
|
||||||
|
chars := companionHumans(expeditionID)
|
||||||
|
if len(chars) == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, dc := range chars {
|
||||||
|
sum += dc.Level
|
||||||
|
}
|
||||||
|
lvl := sum/len(chars) - companionLevelPenalty
|
||||||
|
if lvl < 1 {
|
||||||
|
lvl = 1
|
||||||
|
}
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionPartyClasses reads the classes already on the expedition, for the role
|
||||||
|
// fill. Solo resolves to the owner's class, so a lone fighter gets a healer rather
|
||||||
|
// than the empty-party default.
|
||||||
|
func companionPartyClasses(expeditionID string) []DnDClass {
|
||||||
|
chars := companionHumans(expeditionID)
|
||||||
|
out := make([]DnDClass, 0, len(chars))
|
||||||
|
for _, dc := range chars {
|
||||||
|
out = append(out, dc.Class)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── his sheet, which lives only in memory ────────────────────────────────────
|
||||||
|
|
||||||
|
// companionSheet synthesizes the DnDCharacter Pete fights as. It is built, used,
|
||||||
|
// and thrown away inside a single combat build — it is never saved, and
|
||||||
|
// SaveDnDCharacter must never be called on it.
|
||||||
|
//
|
||||||
|
// Stats come from the same class-priority + race-mod pipeline autoBuildCharacter
|
||||||
|
// uses for a real character, so he is statted like a player of his level rather
|
||||||
|
// than by a bespoke NPC table that would drift away from the tuned math. Human
|
||||||
|
// is deliberate: the +1-to-all is the most neutral race in the book, so his
|
||||||
|
// class is doing the work rather than a race pick nobody chose.
|
||||||
|
func companionSheet(class DnDClass, level int) *DnDCharacter {
|
||||||
|
if level < 1 {
|
||||||
|
level = 1
|
||||||
|
}
|
||||||
|
scores := applyRaceMods(RaceHuman, classStatPriority(class))
|
||||||
|
c := &DnDCharacter{
|
||||||
|
UserID: companionUserID(),
|
||||||
|
Race: RaceHuman,
|
||||||
|
Class: class,
|
||||||
|
Level: level,
|
||||||
|
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||||||
|
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
|
||||||
|
c.HPCurrent = c.HPMax
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionAdvCharacter is the AdventureCharacter half of the synthetic sheet:
|
||||||
|
// the shim DerivePlayerStats needs. CombatLevel round-trips back to the D&D
|
||||||
|
// level through dndLevelFromCombatLevel (level*5), so the two halves agree.
|
||||||
|
func companionAdvCharacter(level int) *AdventureCharacter {
|
||||||
|
return &AdventureCharacter{
|
||||||
|
UserID: companionUserID(),
|
||||||
|
DisplayName: companionDisplayName,
|
||||||
|
CombatLevel: level * 5,
|
||||||
|
Alive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionGearTier maps his level onto the equipment tier a player of that
|
||||||
|
// level would plausibly be carrying: 1–4 → T1, 5–8 → T2, and so on to T5.
|
||||||
|
func companionGearTier(level int) int {
|
||||||
|
t := (level + 3) / 4
|
||||||
|
if t < 1 {
|
||||||
|
t = 1
|
||||||
|
}
|
||||||
|
if t > 5 {
|
||||||
|
t = 5
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionGear is the kit he walks in with — a working reporter's kit, bought
|
||||||
|
// with expenses and kept in serviceable shape.
|
||||||
|
//
|
||||||
|
// He has to have one. The gear layer is not decoration: unarmored, stats.AC is
|
||||||
|
// never set at all (computeArmorAC only fires when armor exists) and he walks in
|
||||||
|
// at AC 3, hit by everything; weaponless, stats.Weapon stays nil and he swings
|
||||||
|
// for a flat 5 at every level, which by L14 is nothing. "No gear" is not a
|
||||||
|
// below-median player, it is a broken one.
|
||||||
|
//
|
||||||
|
// The below-median comes from everywhere else: he is a level down, his gear is
|
||||||
|
// never Masterwork, and he carries no magic items, no subclass and no armed
|
||||||
|
// ability. The weapon names are chosen to hit the right branch of
|
||||||
|
// synthesizeWeaponProfile for the class — it best-fits off the name.
|
||||||
|
func companionGear(class DnDClass, level int) map[EquipmentSlot]*AdvEquipment {
|
||||||
|
tier := companionGearTier(level)
|
||||||
|
|
||||||
|
weapon := "Service Mace"
|
||||||
|
switch class {
|
||||||
|
case ClassFighter, ClassPaladin:
|
||||||
|
weapon = "Service Sword"
|
||||||
|
case ClassRogue:
|
||||||
|
weapon = "Service Dagger"
|
||||||
|
case ClassRanger:
|
||||||
|
weapon = "Service Bow"
|
||||||
|
case ClassMage, ClassSorcerer, ClassWarlock, ClassDruid, ClassBard:
|
||||||
|
weapon = "Service Staff"
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[EquipmentSlot]*AdvEquipment{
|
||||||
|
SlotWeapon: {Slot: SlotWeapon, Tier: tier, Condition: 100, Name: weapon},
|
||||||
|
SlotArmor: {Slot: SlotArmor, Tier: tier, Condition: 100, Name: "Service Kit"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionCombatant builds Pete's seat for a fight, mirroring buildZoneCombatants
|
||||||
|
// but sourcing the sheet from memory instead of the database. He carries no
|
||||||
|
// equipment, no treasure bonuses, no magic items, no chat level and no streak —
|
||||||
|
// the three layers a player accumulates and he never will. That absence *is* the
|
||||||
|
// below-median: he is a bare class chassis at a level below yours, and the gap
|
||||||
|
// between him and a geared player of the same level is exactly the gear.
|
||||||
|
func (p *AdventurePlugin) companionCombatant(
|
||||||
|
class DnDClass, level int, monster DnDMonsterTemplate, tier int, dmMood int,
|
||||||
|
) (Combatant, Combatant, *DnDCharacter) {
|
||||||
|
tilt := dmMoodCombatTilt(dmMood)
|
||||||
|
char := companionAdvCharacter(level)
|
||||||
|
dc := companionSheet(class, level)
|
||||||
|
|
||||||
|
// The layer order is buildZoneCombatants', deliberately — a companion statted
|
||||||
|
// by a different pipeline would drift away from the tuned math the moment
|
||||||
|
// anyone touched one and not the other.
|
||||||
|
//
|
||||||
|
// What he does NOT get is the subclass layer, magic items, and an armed
|
||||||
|
// ability: three of the things a player accumulates and a hireling never will.
|
||||||
|
// Those absences, plus the level penalty and gear that is never Masterwork,
|
||||||
|
// are the "below median" — see companionGear for why the gear itself is not
|
||||||
|
// one of the things we take away.
|
||||||
|
gear := companionGear(class, level)
|
||||||
|
stats, mods := DerivePlayerStats(char, gear, &AdvBonusSummary{}, 0, 0, false)
|
||||||
|
applyDnDPlayerLayer(&stats, dc)
|
||||||
|
applyDnDEquipmentLayer(&stats, dc, gear)
|
||||||
|
applyDnDHPScaling(&stats, dc)
|
||||||
|
applyClassPassives(&stats, &mods, dc)
|
||||||
|
applyRacePassives(&stats, &mods, dc)
|
||||||
|
|
||||||
|
enemyStats, enemyMods := monster.toCombatStats()
|
||||||
|
if tier > 1 {
|
||||||
|
if floorAC := dndDungeonACBase + tier; enemyStats.AC < floorAC {
|
||||||
|
enemyStats.AC = floorAC
|
||||||
|
}
|
||||||
|
if floorAB := dndDungeonAtkBase + tier; enemyStats.AttackBonus < floorAB {
|
||||||
|
enemyStats.AttackBonus = floorAB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enemyStats.Attack += tilt.EnemyAttackDelta
|
||||||
|
if enemyStats.Attack < 1 {
|
||||||
|
enemyStats.Attack = 1
|
||||||
|
}
|
||||||
|
mods.InitiativeBias += tilt.InitiativeBias
|
||||||
|
|
||||||
|
player := Combatant{
|
||||||
|
Name: companionDisplayName,
|
||||||
|
Stats: stats,
|
||||||
|
Mods: mods,
|
||||||
|
IsPlayer: true,
|
||||||
|
}
|
||||||
|
enemy := Combatant{
|
||||||
|
Name: monster.Name,
|
||||||
|
Stats: enemyStats,
|
||||||
|
Mods: enemyMods,
|
||||||
|
Ability: monster.Ability,
|
||||||
|
}
|
||||||
|
return player, enemy, dc
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionRosterLine is how he reads on `!expedition party`. DisplayName would
|
||||||
|
// come back empty for him — he has no player_meta row to hold a name — so the
|
||||||
|
// roster names him here, along with what he is currently playing, because "Pete
|
||||||
|
// (member)" tells the leader nothing about the hole they paid to fill.
|
||||||
|
func companionRosterLine(expeditionID string) string {
|
||||||
|
class, level := companionLoadout(expeditionID)
|
||||||
|
ci, _ := classInfo(class)
|
||||||
|
return fmt.Sprintf("**%s** _(hired — level %d %s)_\n", companionDisplayName, level, ci.Display)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the hire, persisted on the roster ────────────────────────────────────────
|
||||||
|
|
||||||
|
// companionLoadout reads back the class and level he was hired at. It is stored
|
||||||
|
// on the roster row rather than re-derived per fight, so a party that levels
|
||||||
|
// mid-expedition doesn't quietly re-roll their hireling into a different class
|
||||||
|
// three rooms in.
|
||||||
|
func companionLoadout(expeditionID string) (DnDClass, int) {
|
||||||
|
var class string
|
||||||
|
var level int
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT companion_class, companion_level
|
||||||
|
FROM expedition_party
|
||||||
|
WHERE expedition_id = ? AND user_id = ?`,
|
||||||
|
expeditionID, string(companionUserID())).Scan(&class, &level)
|
||||||
|
if err != nil || class == "" {
|
||||||
|
return ClassFighter, 1
|
||||||
|
}
|
||||||
|
return DnDClass(class), level
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── his spell slots, which live on the expedition ────────────────────────────
|
||||||
|
//
|
||||||
|
// A human caster's slots are dnd_spell_slots rows: one pool, spent across every
|
||||||
|
// fight of the run, refilled only at camp. Rationing it is the caster's game. The
|
||||||
|
// companion has no rows, so his pool lives on his roster row — the same row his
|
||||||
|
// class and level live on, and with the same lifetime.
|
||||||
|
//
|
||||||
|
// It must NOT live on his combat seat. A seat is per-session and every fight opens
|
||||||
|
// a new one, so a seat-scoped pool refills itself between fights: an infinite
|
||||||
|
// caster. That is not a theory — the first cut did exactly that, and the sim
|
||||||
|
// measured a gearless, level-penalized hireling out-clearing a human cleric of the
|
||||||
|
// leader's own level by 15pp.
|
||||||
|
|
||||||
|
// companionSlotsCSV encodes/decodes the ledger. CSV of six ints rather than JSON
|
||||||
|
// because it is six ints.
|
||||||
|
func companionSlotsDecode(s string) [6]int {
|
||||||
|
var out [6]int
|
||||||
|
for i, f := range strings.Split(s, ",") {
|
||||||
|
if i >= len(out) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(f))
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[i] = n
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func companionSlotsEncode(used [6]int) string {
|
||||||
|
parts := make([]string, len(used))
|
||||||
|
for i, n := range used {
|
||||||
|
parts[i] = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionSlotsForRun reads the ledger for the companion on the expedition that
|
||||||
|
// owns runID. A run with no companion (or no expedition) reads as an empty pool,
|
||||||
|
// which is the correct answer: nobody spent anything.
|
||||||
|
func companionSlotsForRun(runID string) [6]int {
|
||||||
|
var raw string
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT p.companion_slots_used
|
||||||
|
FROM expedition_party p
|
||||||
|
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||||
|
WHERE e.run_id = ? AND p.user_id = ?`,
|
||||||
|
runID, string(companionUserID())).Scan(&raw)
|
||||||
|
if err != nil {
|
||||||
|
return [6]int{}
|
||||||
|
}
|
||||||
|
return companionSlotsDecode(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCompanionSlotsForRun writes it back.
|
||||||
|
func setCompanionSlotsForRun(runID string, used [6]int) error {
|
||||||
|
_, err := db.Get().Exec(`
|
||||||
|
UPDATE expedition_party
|
||||||
|
SET companion_slots_used = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
|
||||||
|
companionSlotsEncode(used), string(companionUserID()), runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshCompanionSlots empties the ledger — his half of the camp rest that calls
|
||||||
|
// refreshSpellSlots for every human. Keyed by expedition, because camp is.
|
||||||
|
func refreshCompanionSlots(expeditionID string) error {
|
||||||
|
_, err := db.Get().Exec(`
|
||||||
|
UPDATE expedition_party SET companion_slots_used = ''
|
||||||
|
WHERE expedition_id = ? AND user_id = ?`,
|
||||||
|
expeditionID, string(companionUserID()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── his body, which is also carried across the run ───────────────────────────
|
||||||
|
//
|
||||||
|
// companionUnsetHP is "no wound recorded" — a fresh hire, or a companion who has
|
||||||
|
// just broken camp. Seating reads it as full.
|
||||||
|
const companionUnsetHP = -1
|
||||||
|
|
||||||
|
// companionHPFor reads the HP he carries into his next fight, or companionUnsetHP
|
||||||
|
// when he is unhurt. A run with no companion reads unset, which is harmless: there
|
||||||
|
// is nobody to seat.
|
||||||
|
func companionHPFor(expeditionID string) int {
|
||||||
|
hp := companionUnsetHP
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT companion_hp FROM expedition_party
|
||||||
|
WHERE expedition_id = ? AND user_id = ?`,
|
||||||
|
expeditionID, string(companionUserID())).Scan(&hp)
|
||||||
|
if err != nil {
|
||||||
|
return companionUnsetHP
|
||||||
|
}
|
||||||
|
return hp
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionSeatHP is what he actually sits down with: his carried wound, clamped
|
||||||
|
// into [1, maxHP].
|
||||||
|
//
|
||||||
|
// The floor of 1 is deliberate. He can be dropped *inside* a fight — the engine
|
||||||
|
// counts him out like any other seat — but he does not stay dead between them,
|
||||||
|
// because there is no companion-death mechanic and inventing one here would be a
|
||||||
|
// second feature smuggled into a bug fix. Coming back on 1 HP is a real penalty
|
||||||
|
// (one hit and he is down again) without pretending to be a corpse rule.
|
||||||
|
func companionSeatHP(expeditionID string, maxHP int) int {
|
||||||
|
hp := companionHPFor(expeditionID)
|
||||||
|
if hp == companionUnsetHP || hp > maxHP {
|
||||||
|
return maxHP
|
||||||
|
}
|
||||||
|
if hp < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return hp
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCompanionHP records the HP he walked out of a fight with.
|
||||||
|
func setCompanionHP(expeditionID string, hp int) error {
|
||||||
|
_, err := db.Get().Exec(`
|
||||||
|
UPDATE expedition_party SET companion_hp = ?
|
||||||
|
WHERE expedition_id = ? AND user_id = ?`,
|
||||||
|
hp, expeditionID, string(companionUserID()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCompanionHPForRun is setCompanionHP for the turn-based close-out, which
|
||||||
|
// holds a run id rather than an expedition id.
|
||||||
|
func setCompanionHPForRun(runID string, hp int) error {
|
||||||
|
_, err := db.Get().Exec(`
|
||||||
|
UPDATE expedition_party
|
||||||
|
SET companion_hp = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
|
||||||
|
hp, string(companionUserID()), runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshCompanionHP is his half of the camp heal: back to full, like every human
|
||||||
|
// at a standard rest.
|
||||||
|
func refreshCompanionHP(expeditionID string) error {
|
||||||
|
_, err := db.Get().Exec(`
|
||||||
|
UPDATE expedition_party SET companion_hp = ?
|
||||||
|
WHERE expedition_id = ? AND user_id = ?`,
|
||||||
|
companionUnsetHP, expeditionID, string(companionUserID()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionLoadoutForRun is companionLoadout keyed by the zone run instead of
|
||||||
|
// the expedition. A CombatSession carries a RunID, not an expedition id, and the
|
||||||
|
// per-turn rebuild is the hottest caller — so the join lives here rather than
|
||||||
|
// making every caller resolve the expedition first.
|
||||||
|
//
|
||||||
|
// A run with no expedition (a standalone !zone run, which can have no companion)
|
||||||
|
// finds no row and falls back, which is the correct reading.
|
||||||
|
func companionLoadoutForRun(runID string) (DnDClass, int) {
|
||||||
|
var class string
|
||||||
|
var level int
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT p.companion_class, p.companion_level
|
||||||
|
FROM expedition_party p
|
||||||
|
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||||
|
WHERE e.run_id = ? AND p.user_id = ?`,
|
||||||
|
runID, string(companionUserID())).Scan(&class, &level)
|
||||||
|
if err != nil || class == "" {
|
||||||
|
return ClassFighter, 1
|
||||||
|
}
|
||||||
|
return DnDClass(class), level
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionExpeditionFor resolves the expedition a leader is running, for the
|
||||||
|
// seat builder — which has the roster but not the expedition id. Empty string
|
||||||
|
// when there is none, which companionLoadout reads as "no row" and falls back.
|
||||||
|
func companionExpeditionFor(leader id.UserID) string {
|
||||||
|
e, _, err := activeExpeditionFor(leader)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionSeated reports whether Pete is on this roster.
|
||||||
|
func companionSeated(expeditionID string) bool {
|
||||||
|
var one int
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT 1 FROM expedition_party WHERE expedition_id = ? AND user_id = ?`,
|
||||||
|
expeditionID, string(companionUserID())).Scan(&one)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hireCompanion seats Pete. The seat check, the availability check and the
|
||||||
|
// insert share a transaction, so two leaders racing for him cannot both win.
|
||||||
|
//
|
||||||
|
// He is globally exclusive — one party at a time. That is not a limitation to
|
||||||
|
// route around: "Pete is out on assignment" is the scarcity knob the plan asks
|
||||||
|
// for, and it is also why he cannot be the answer to every run.
|
||||||
|
func hireCompanion(expeditionID string, class DnDClass, level int) error {
|
||||||
|
tx, err := db.Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if err := seatLeader(tx, expeditionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is he already out with somebody? Scoped to live expeditions, so a roster
|
||||||
|
// row stranded by a crash cannot make him unhireable forever.
|
||||||
|
var busyOn string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
SELECT p.expedition_id
|
||||||
|
FROM expedition_party p
|
||||||
|
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||||
|
WHERE p.user_id = ? AND e.status IN ('active', 'extracting')
|
||||||
|
LIMIT 1`, string(companionUserID())).Scan(&busyOn)
|
||||||
|
switch {
|
||||||
|
case err == nil && busyOn == expeditionID:
|
||||||
|
return ErrCompanionAlreadyHired
|
||||||
|
case err == nil:
|
||||||
|
return ErrCompanionOnAssignment
|
||||||
|
case !errors.Is(err, sql.ErrNoRows):
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := tx.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
|
||||||
|
expeditionID).Scan(&n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n >= expeditionPartyMax {
|
||||||
|
return ErrPartyFull
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO expedition_party (expedition_id, user_id, role, companion_class, companion_level)
|
||||||
|
VALUES (?, ?, 'member', ?, ?)`,
|
||||||
|
expeditionID, string(companionUserID()), string(class), level); err != nil {
|
||||||
|
return fmt.Errorf("seat companion: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dismissCompanion sends him home. Unlike a player member he can be removed
|
||||||
|
// mid-run — he is staff, not a guest — but the fee is not refunded: he already
|
||||||
|
// walked in.
|
||||||
|
func dismissCompanion(expeditionID string) error {
|
||||||
|
res, err := db.Get().Exec(`
|
||||||
|
DELETE FROM expedition_party
|
||||||
|
WHERE expedition_id = ? AND user_id = ?`,
|
||||||
|
expeditionID, string(companionUserID()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrCompanionNotHired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the dispatch ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// emitCompanionHireFact tells Pete's bot he has been hired. gogobee states the
|
||||||
|
// fact; Pete writes the sentence. Bulletin, not priority: a hire is a diary
|
||||||
|
// entry, not a bulletin-interrupting event.
|
||||||
|
//
|
||||||
|
// The subject is the *leader*, so the leader's news opt-out is honoured — Pete
|
||||||
|
// reporting "filled in as cleric for <someone who asked not to be named>" would
|
||||||
|
// be exactly the leak the opt-out exists to prevent. Pete himself is never a
|
||||||
|
// subject: he has no opt-out row and needs none.
|
||||||
|
func emitCompanionHireFact(leader id.UserID, class DnDClass, level int, zone ZoneDefinition) {
|
||||||
|
name := charName(leader)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ci, _ := classInfo(class)
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: "companion_hire:" + eventToken(leader, "companion_hire") + ":" + fmt.Sprint(time.Now().UTC().Unix()),
|
||||||
|
EventType: "companion_hire",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: name,
|
||||||
|
Zone: zone.Display,
|
||||||
|
Level: level,
|
||||||
|
ClassRace: ci.Display,
|
||||||
|
OccurredAt: time.Now().UTC().Unix(),
|
||||||
|
}, leader, "")
|
||||||
|
}
|
||||||
309
internal/plugin/adventure_companion_test.go
Normal file
309
internal/plugin/adventure_companion_test.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The companion's whole contract is "he fights, and he is not a player". These
|
||||||
|
// tests pin both halves — and specifically the seams where an NPC seat would
|
||||||
|
// otherwise be silently treated as a person.
|
||||||
|
|
||||||
|
func TestCompanion_HiredSeatIsNotAMouth(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
owner := id.UserID("@leader:example.org")
|
||||||
|
seedExpedition(t, "exp-hire", owner, "active")
|
||||||
|
seatLeaderFixture(t, "exp-hire")
|
||||||
|
|
||||||
|
if err := hireCompanion("exp-hire", ClassCleric, 4); err != nil {
|
||||||
|
t.Fatalf("hireCompanion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The roster holds two seats...
|
||||||
|
members, err := partyMembers("exp-hire")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(members) != 2 {
|
||||||
|
t.Fatalf("roster has %d seats, want 2 (leader + companion)", len(members))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...but only one of them eats. partySize feeds the daily supply burn and the
|
||||||
|
// "your party is still waiting on you" lock-out; counting the companion would
|
||||||
|
// bill the leader for rations he never bought, and strand him out of his next
|
||||||
|
// expedition behind a party of one bot.
|
||||||
|
n, err := partySize("exp-hire")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("partySize = %d, want 1 — the companion is not a mouth", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompanion_GetsNoMailButTakesASeat(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
owner := id.UserID("@leader2:example.org")
|
||||||
|
seedExpedition(t, "exp-mail", owner, "active")
|
||||||
|
seatLeaderFixture(t, "exp-mail")
|
||||||
|
if err := hireCompanion("exp-mail", ClassFighter, 3); err != nil {
|
||||||
|
t.Fatalf("hireCompanion: %v", err)
|
||||||
|
}
|
||||||
|
exp, err := getExpedition("exp-mail")
|
||||||
|
if err != nil || exp == nil {
|
||||||
|
t.Fatalf("getExpedition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mail and seats are different sets. Every DM seam reads the audience; the
|
||||||
|
// combat roster reads the seats. Getting this backwards either DMs a bot or
|
||||||
|
// charges a leader for a body that never sits down.
|
||||||
|
for _, uid := range expeditionAudience(exp) {
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
t.Fatal("companion is in the DM audience — he does not get mail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var seated bool
|
||||||
|
for _, uid := range expeditionSeats(exp) {
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
seated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seated {
|
||||||
|
t.Fatal("companion is not in the fight roster — he was paid for and never sat down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompanion_IsGloballyExclusive(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
for _, e := range []struct {
|
||||||
|
id string
|
||||||
|
owner id.UserID
|
||||||
|
}{{"exp-a", "@a:example.org"}, {"exp-b", "@b:example.org"}} {
|
||||||
|
seedExpedition(t, e.id, e.owner, "active")
|
||||||
|
seatLeaderFixture(t, e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hireCompanion("exp-a", ClassMage, 5); err != nil {
|
||||||
|
t.Fatalf("first hire: %v", err)
|
||||||
|
}
|
||||||
|
// He is one person. A second party cannot have him — "out on assignment" is
|
||||||
|
// the scarcity knob, not a bug to route around.
|
||||||
|
if err := hireCompanion("exp-b", ClassMage, 5); !errors.Is(err, ErrCompanionOnAssignment) {
|
||||||
|
t.Errorf("second hire err = %v, want ErrCompanionOnAssignment", err)
|
||||||
|
}
|
||||||
|
// Re-hiring him into the party he's already with is its own answer.
|
||||||
|
if err := hireCompanion("exp-a", ClassMage, 5); !errors.Is(err, ErrCompanionAlreadyHired) {
|
||||||
|
t.Errorf("re-hire err = %v, want ErrCompanionAlreadyHired", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismissed, he's available again.
|
||||||
|
if err := dismissCompanion("exp-a"); err != nil {
|
||||||
|
t.Fatalf("dismiss: %v", err)
|
||||||
|
}
|
||||||
|
if err := hireCompanion("exp-b", ClassMage, 5); err != nil {
|
||||||
|
t.Errorf("hire after dismiss: %v, want success", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bug that made the whole feature worse than useless: a SOLO expedition has
|
||||||
|
// no expedition_party rows at all (the roster only materializes on the first
|
||||||
|
// invite), so reading the roster to size the companion answered "nobody" for
|
||||||
|
// exactly the player who is hiring him — the one with no friends around. Every
|
||||||
|
// solo hire got a level-1 Pete, in whatever tier the leader was actually in.
|
||||||
|
func TestCompanion_SoloLeaderSizesHimCorrectly(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
owner := id.UserID("@lonely:example.org")
|
||||||
|
seedExpedition(t, "exp-solo-hire", owner, "active")
|
||||||
|
|
||||||
|
// A level-12 fighter, adventuring alone. No roster rows exist.
|
||||||
|
if err := SaveDnDCharacter(&DnDCharacter{
|
||||||
|
UserID: owner, Race: RaceHuman, Class: ClassFighter, Level: 12,
|
||||||
|
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||||
|
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := companionPartyLevel("exp-solo-hire"); got != 11 {
|
||||||
|
t.Errorf("companionPartyLevel = %d, want 11 (the lone leader's 12, less the below-median step). "+
|
||||||
|
"A level-1 companion in the leader's zone is worse than no companion at all.", got)
|
||||||
|
}
|
||||||
|
// And he fills the hole the lone fighter actually has, rather than defaulting
|
||||||
|
// as if the party were empty.
|
||||||
|
if got := companionRoleFill(companionPartyClasses("exp-solo-hire")); got != ClassCleric {
|
||||||
|
t.Errorf("role fill for a lone fighter = %v, want cleric", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4 — asking for "the party" must never answer "nobody".
|
||||||
|
//
|
||||||
|
// A solo expedition has no expedition_party rows (absence means solo; the roster
|
||||||
|
// materializes on the first invite). Every consumer that read the roster table
|
||||||
|
// directly therefore got an empty list for a solo player and fell back to
|
||||||
|
// whatever looked reasonable locally. That is how the companion was hired at
|
||||||
|
// level 1 for exactly the player the feature exists for.
|
||||||
|
//
|
||||||
|
// expeditionParty always includes the owner. If this test ever fails, that
|
||||||
|
// guarantee is gone and the same class of bug is back.
|
||||||
|
func TestParty_SoloExpeditionStillHasAParty(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
owner := id.UserID("@alone:example.org")
|
||||||
|
seedExpedition(t, "exp-alone", owner, "active")
|
||||||
|
|
||||||
|
// No roster rows exist at all.
|
||||||
|
rows, err := partyMembers("exp-alone")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("solo expedition has %d roster rows; the premise of this test is gone", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And yet the party is not empty.
|
||||||
|
seats, err := expeditionParty("exp-alone", string(owner))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(seats) != 1 || seats[0].UserID != owner || seats[0].Kind != SeatLeader {
|
||||||
|
t.Fatalf("expeditionParty on a solo run = %+v, want exactly the owner as leader. "+
|
||||||
|
"An empty answer here is what hires a level-1 companion.", seats)
|
||||||
|
}
|
||||||
|
humans, err := partyHumans("exp-alone", string(owner))
|
||||||
|
if err != nil || len(humans) != 1 {
|
||||||
|
t.Fatalf("partyHumans on a solo run = %+v (err %v), want the owner", humans, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompanion_RoleFillTakesTheHole(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
party []DnDClass
|
||||||
|
want DnDClass
|
||||||
|
}{
|
||||||
|
{"no healer", []DnDClass{ClassFighter, ClassRogue}, ClassCleric},
|
||||||
|
{"no front line", []DnDClass{ClassCleric, ClassMage}, ClassFighter},
|
||||||
|
{"no damage", []DnDClass{ClassCleric, ClassFighter}, ClassMage},
|
||||||
|
// A paladin covers healer AND front line, so paladin+rogue has no hole at
|
||||||
|
// all — it falls through to the default, a second body up front.
|
||||||
|
{"a complete party gets the default", []DnDClass{ClassPaladin, ClassRogue}, ClassFighter},
|
||||||
|
{"a paladin still leaves the damage hole", []DnDClass{ClassPaladin}, ClassMage},
|
||||||
|
{"solo fighter needs the medic first", []DnDClass{ClassFighter}, ClassCleric},
|
||||||
|
{"empty party", nil, ClassCleric},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := companionRoleFill(tc.party); got != tc.want {
|
||||||
|
t.Errorf("companionRoleFill(%v) = %v, want %v", tc.party, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §3 — an engine-driven seat's latch is permanent, and no command can take it.
|
||||||
|
//
|
||||||
|
// This is the invariant that, when it did not exist, made the companion stand in
|
||||||
|
// fights doing nothing. The expedition autopilot drives a party by dispatching
|
||||||
|
// each seat's turn AS that seat; his own auto-played move therefore arrived at
|
||||||
|
// beginCombatTurn looking exactly like a human returning to the keyboard, and the
|
||||||
|
// "they typed, so they're here" branch cleared the latch that was moving him.
|
||||||
|
// After round 1 he never acted again — while the boss he had inflated by 15% HP
|
||||||
|
// killed the party. The sweep measured it at -27pp clear rate.
|
||||||
|
//
|
||||||
|
// Autopilot is provisional (a human is away; a keystroke ends it). EngineDriven is
|
||||||
|
// not (there is nobody to come back). They must never collapse into one flag.
|
||||||
|
func TestCompanion_EngineSeatKeepsTheWheel(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid := id.UserID("@lead:example.org")
|
||||||
|
|
||||||
|
monster := dndBestiary["goblin"]
|
||||||
|
c, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
|
||||||
|
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
|
||||||
|
|
||||||
|
sess, err := p.startPartyCombatSession("run-e", "enc", "goblin", &enemy, []CombatSeatSetup{
|
||||||
|
{UserID: uid, HP: 200, HPMax: 200, Mods: c.Mods, C: &c},
|
||||||
|
{UserID: companionUserID(), HP: 120, HPMax: 120, Mods: c.Mods, C: &c, EngineDriven: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sess.seatIsEngineDriven(1) {
|
||||||
|
t.Fatal("companion seat is not engine-driven")
|
||||||
|
}
|
||||||
|
if sess.seatIsEngineDriven(0) {
|
||||||
|
t.Fatal("the human's seat came out engine-driven")
|
||||||
|
}
|
||||||
|
// The engine drives it without waiting on anybody...
|
||||||
|
if !sess.seatIsAutopiloted(1) || !sess.seatNeedsNoHuman(1) {
|
||||||
|
t.Fatal("an engine seat must resolve without waiting for a human")
|
||||||
|
}
|
||||||
|
// ...and the human's seat still waits for its human.
|
||||||
|
if sess.seatIsAutopiloted(0) {
|
||||||
|
t.Fatal("the human's seat was latched onto autopilot at seating")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wheel cannot be taken back, because there is nobody to take it. This is
|
||||||
|
// the exact line that used to strand him: a "keystroke" from his own id.
|
||||||
|
sess.actorStatusesPtr(1).Autopilot = false
|
||||||
|
if !sess.seatIsAutopiloted(1) {
|
||||||
|
t.Fatal("clearing Autopilot stranded the engine seat — EngineDriven must keep it moving")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one that actually matters: he has to FIGHT. Every other guard in this
|
||||||
|
// file is about keeping him out of things, and a companion synthesized down to
|
||||||
|
// zeroed stats would pass all of them while standing in the fight doing nothing
|
||||||
|
// — a hire that silently buys the leader an empty chair.
|
||||||
|
func TestCompanion_ActuallySwings(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
|
||||||
|
monster := dndBestiary["goblin"]
|
||||||
|
tank := monster
|
||||||
|
tank.HP = 5000 // outlast the phase clock, so a real swing has time to land
|
||||||
|
|
||||||
|
pete, _, _ := p.companionCombatant(ClassFighter, 6, tank, 2, 0)
|
||||||
|
|
||||||
|
if pete.Stats.MaxHP <= 0 || pete.Stats.Attack <= 0 || pete.Stats.AC <= 0 {
|
||||||
|
t.Fatalf("companion built with dead stats (%d HP / %d atk / %d AC) — he'd stand in the fight and do nothing",
|
||||||
|
pete.Stats.MaxHP, pete.Stats.Attack, pete.Stats.AC)
|
||||||
|
}
|
||||||
|
|
||||||
|
enemy := Combatant{Name: tank.Name, Stats: CombatStats{MaxHP: 5000, AC: 10, Attack: 1, AttackBonus: 1}}
|
||||||
|
res := simulatePartyWithRNG([]Combatant{pete}, enemy, dungeonCombatPhases, seededRNG(7))
|
||||||
|
|
||||||
|
var swings int
|
||||||
|
for _, e := range res.Events {
|
||||||
|
if e.Actor == "player" && e.Seat == 0 && e.Roll > 0 {
|
||||||
|
swings++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if swings == 0 {
|
||||||
|
t.Fatal("the companion never swung — he was hired, seated, and did nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompanion_SheetIsBelowMedianAndNeverPersisted(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
|
||||||
|
// He is statted like a player of his level, so he never drifts from the tuned
|
||||||
|
// math — but he arrives a level down, which is the whole "help, never a carry"
|
||||||
|
// rule expressed in one number.
|
||||||
|
dc := companionSheet(ClassFighter, 7)
|
||||||
|
if dc.Level != 7 || dc.HPMax <= 0 {
|
||||||
|
t.Fatalf("companionSheet = level %d / %d HP, want a real level-7 chassis", dc.Level, dc.HPMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And he leaves no trace: a player_meta row is the thing that would turn him
|
||||||
|
// into a real character everywhere (graveyard, news, XP, leaderboards), so the
|
||||||
|
// synthesis must not have written one.
|
||||||
|
if c, _ := LoadDnDCharacter(companionUserID()); c != nil {
|
||||||
|
t.Fatal("companion has a persisted dnd_character row — he is a player now, which is the one thing he must never be")
|
||||||
|
}
|
||||||
|
if _, err := loadAdvCharacter(companionUserID()); err == nil {
|
||||||
|
t.Fatal("companion has a persisted player_meta row — ensureDnDCharacterForCombat will auto-build him a character on his first swing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -613,6 +614,22 @@ func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, c
|
|||||||
upsertRivalRecord(winnerID, loserID, true)
|
upsertRivalRecord(winnerID, loserID, true)
|
||||||
upsertRivalRecord(loserID, winnerID, false)
|
upsertRivalRecord(loserID, winnerID, false)
|
||||||
|
|
||||||
|
// One BULLETIN dispatch per settled duel (from the winner's side, so it
|
||||||
|
// fires once). Character names only; no-op unless the seam is enabled.
|
||||||
|
if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" {
|
||||||
|
ts := nowUnix()
|
||||||
|
disc := fmt.Sprintf("rival:%d", ts)
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), ts),
|
||||||
|
EventType: "rival_result",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: wn,
|
||||||
|
Opponent: ln,
|
||||||
|
Outcome: "won",
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, winnerID, loserID)
|
||||||
|
}
|
||||||
|
|
||||||
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
|
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
1160
internal/plugin/adventure_mischief.go
Normal file
1160
internal/plugin/adventure_mischief.go
Normal file
File diff suppressed because it is too large
Load Diff
600
internal/plugin/adventure_mischief_deliver.go
Normal file
600
internal/plugin/adventure_mischief_deliver.go
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// Mischief Makers (M1) — delivery.
|
||||||
|
//
|
||||||
|
// The sibling of the ambient ticker: once a contract's window closes, the
|
||||||
|
// monster has to actually find the target mid-run. runMischiefInterrupt is
|
||||||
|
// modelled line-for-line on runHarvestInterrupt (pick enemy → ambush nick →
|
||||||
|
// runZoneCombatRoster → close out), with two deliberate departures:
|
||||||
|
//
|
||||||
|
// - It never calls recordZoneKill and never advances zone state. The fight is
|
||||||
|
// extrinsic to the dungeon: somebody bought it, the dungeon didn't send it.
|
||||||
|
// Crediting it as a zone kill would let a buyer unlock a target's
|
||||||
|
// RequiresKill resource gates for them.
|
||||||
|
//
|
||||||
|
// - It never marks anybody dead. runHarvestInterrupt's loss path perma-kills;
|
||||||
|
// a *purchased* attack that lands while its victim is asleep must not be
|
||||||
|
// able to delete their character. Mischief maims: floor at 1 HP,
|
||||||
|
// force-extract, take a bite out of the un-banked coins.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mischiefTickInterval — how often we look for contracts whose window has
|
||||||
|
// closed. The window itself is the pacing; the tick just has to be fine-grained
|
||||||
|
// enough that "about an hour" isn't a lie.
|
||||||
|
const mischiefTickInterval = time.Minute
|
||||||
|
|
||||||
|
// mischiefDeliveryGrace — how long past its window a contract may sit in
|
||||||
|
// `delivering` before the sweep calls it stranded. Comfortably longer than any
|
||||||
|
// chain of auto-resolved fights takes, so a slow delivery is never swept out
|
||||||
|
// from under itself (and the CAS in abandonStaleMischief is the backstop if it
|
||||||
|
// somehow is).
|
||||||
|
const mischiefDeliveryGrace = 15 * time.Minute
|
||||||
|
|
||||||
|
// mischiefTreasureWeight / mischiefRenownXP — the survival extras. Deliberately
|
||||||
|
// modest: the purse is the reward, this is the garnish.
|
||||||
|
const (
|
||||||
|
mischiefTreasureWeight = 1.0
|
||||||
|
mischiefEliteRenownXP = 40
|
||||||
|
mischiefBossRenownXP = 120
|
||||||
|
mischiefBossCacheSize = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) mischiefTicker() {
|
||||||
|
ticker := time.NewTicker(mischiefTickInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
p.fireMischiefDeliveries(time.Now().UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireMischiefDeliveries walks contracts whose window has closed and either
|
||||||
|
// delivers the monster or fizzles the contract.
|
||||||
|
//
|
||||||
|
// The gates mirror the ambient ticker's, for the same reason: a mid-run event
|
||||||
|
// that talks over a live turn-based fight, or lands on top of the 06:00
|
||||||
|
// briefing, reads as a bug even when it isn't. A blocked contract is NOT
|
||||||
|
// fizzled — it simply waits for the next tick. The only thing that fizzles a
|
||||||
|
// contract is the expedition ending, which is what the buyer was actually
|
||||||
|
// betting against.
|
||||||
|
func (p *AdventurePlugin) fireMischiefDeliveries(now time.Time) {
|
||||||
|
if inAmbientQuietWindow(now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.sweepStaleMischief(now)
|
||||||
|
for _, c := range loadMischiefDue(now) {
|
||||||
|
contract := c
|
||||||
|
p.fireOneMischiefDelivery(&contract)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireOneMischiefDelivery resolves a single due contract under the TARGET's lock.
|
||||||
|
//
|
||||||
|
// The lock is not optional. hasActiveCombatSession only sees the turn-based
|
||||||
|
// engine; the target's own `!explore` / autopilot walk resolves its fights
|
||||||
|
// inline (runHarvestInterrupt → runZoneCombatRoster) under advUserLock and
|
||||||
|
// reports no session. Without taking that same lock, a delivery can run a second
|
||||||
|
// combat against the same character sheet concurrently — two LoadDnDCharacter →
|
||||||
|
// mutate → SaveDnDCharacter writers, last write wins, and a whole fight's HP cost
|
||||||
|
// vanishes. The boredom ticker takes this lock for exactly this reason.
|
||||||
|
//
|
||||||
|
// Everything below the lock runs on the auto-resolve path, which takes no user
|
||||||
|
// locks of its own, so there is nothing here to deadlock against.
|
||||||
|
func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) {
|
||||||
|
target := contract.TargetID
|
||||||
|
|
||||||
|
lock := p.advUserLock(target)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
// Re-read under the lock: whatever the due-sweep saw, the expedition may have
|
||||||
|
// ended while we were queuing behind the target's own command.
|
||||||
|
exp, err := getActiveExpedition(target)
|
||||||
|
if err != nil || exp == nil || exp.Status != ExpeditionStatusActive {
|
||||||
|
p.fizzleMischief(contract)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same safety net the ambient ticker keeps: the expedition row can still
|
||||||
|
// say 'active' after the player has functionally left the dungeon.
|
||||||
|
if exp.RunID != "" {
|
||||||
|
if run, _ := getZoneRun(exp.RunID); run == nil || !run.IsActive() {
|
||||||
|
p.fizzleMischief(contract)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasActiveCombatSession(target) {
|
||||||
|
return // they're mid-fight; the monster can wait a minute
|
||||||
|
}
|
||||||
|
if !claimMischiefForDelivery(contract.ID) {
|
||||||
|
return // another tick (or another process) already has it
|
||||||
|
}
|
||||||
|
// Re-read AFTER the claim, never before it. The row we were handed came from
|
||||||
|
// the due-sweep, and the window's commands (bless, escalate) keep writing to an
|
||||||
|
// open contract right up to the moment the claim closes it. A ward bought in
|
||||||
|
// that gap would be paid for and never applied; an escalation would be paid for
|
||||||
|
// and deliver the *old* tier's monster. The claim is the fence — everything the
|
||||||
|
// fight is built from has to be read on this side of it.
|
||||||
|
if fresh, err := mischiefContractByID(contract.ID); err == nil && fresh != nil {
|
||||||
|
contract = fresh
|
||||||
|
}
|
||||||
|
if err := p.deliverMischief(contract, exp); err != nil {
|
||||||
|
slog.Error("mischief: delivery failed",
|
||||||
|
"contract", contract.ID, "target", target, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepStaleMischief releases contracts stranded in `delivering` — the process
|
||||||
|
// died between the claim and the close-out (a restart mid-fight, a panic). The
|
||||||
|
// row would otherwise be immortal: the target can never be targeted again, and
|
||||||
|
// the buyer's money never comes back.
|
||||||
|
//
|
||||||
|
// Everyone who paid is made whole in full rather than rake-charged. A stranded
|
||||||
|
// delivery is our crash, not a bet they lost — and the escalator paid too, so the
|
||||||
|
// refund splits on mischiefOutlay.
|
||||||
|
//
|
||||||
|
// The ward comes back off the sheet here as well. The crash is exactly the case
|
||||||
|
// runMischiefInterrupt's `defer clearMischiefBlessings` cannot cover, and a ward
|
||||||
|
// left behind is a permanent free cushion on the target until something else
|
||||||
|
// happens to reset TempHP.
|
||||||
|
func (p *AdventurePlugin) sweepStaleMischief(now time.Time) {
|
||||||
|
for _, c := range loadStaleMischiefDeliveries(now.Add(-mischiefDeliveryGrace)) {
|
||||||
|
contract := c
|
||||||
|
if !abandonStaleMischief(contract.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.clearStrandedMischiefWard(&contract)
|
||||||
|
|
||||||
|
buyerPaid, escPaid := mischiefOutlay(&contract)
|
||||||
|
p.euro.Credit(contract.BuyerID, float64(buyerPaid), "mischief_refund_stranded")
|
||||||
|
p.SendDM(contract.BuyerID, fmt.Sprintf(
|
||||||
|
"😾 Your **%s** never reached **%s** — something went wrong on our end. Fully refunded: %s.",
|
||||||
|
contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(buyerPaid)))
|
||||||
|
if escPaid > 0 {
|
||||||
|
p.euro.Credit(contract.EscalatedBy, float64(escPaid), "mischief_refund_stranded")
|
||||||
|
p.SendDM(contract.EscalatedBy, fmt.Sprintf(
|
||||||
|
"😾 The **%s** you paid to upgrade never reached **%s** — something went wrong on our end. "+
|
||||||
|
"Fully refunded: %s.",
|
||||||
|
contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(escPaid)))
|
||||||
|
}
|
||||||
|
slog.Warn("mischief: swept stranded delivery",
|
||||||
|
"contract", contract.ID, "target", contract.TargetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearStrandedMischiefWard takes back a ward whose delivery died before it could
|
||||||
|
// clear its own. The amount is re-derived exactly as applyMischiefBlessings
|
||||||
|
// derived it, off the target's current MaxHP.
|
||||||
|
//
|
||||||
|
// If the crash landed BEFORE the ward was ever applied, this over-subtracts into
|
||||||
|
// a well-rested cushion the target had of their own. That window is a handful of
|
||||||
|
// DB reads wide against a whole fight, clearMischiefBlessings floors at 0, and the
|
||||||
|
// alternative — leaving a stranded ward on the sheet forever — is the strictly
|
||||||
|
// worse failure.
|
||||||
|
func (p *AdventurePlugin) clearStrandedMischiefWard(c *mischiefContract) {
|
||||||
|
if c.BlessingCount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dc, err := LoadDnDCharacter(c.TargetID)
|
||||||
|
if err != nil || dc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ward := mischiefBlessingTempHP(dc.HPMax, c.BlessingCount); ward > 0 {
|
||||||
|
p.clearMischiefBlessings(c.TargetID, ward)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fizzleMischief refunds everyone who put money on the contract (minus the rake)
|
||||||
|
// when the monster arrives to an empty dungeon. The CAS is what makes the refund
|
||||||
|
// safe: a contract that was simultaneously claimed for delivery cannot also be
|
||||||
|
// refunded here.
|
||||||
|
//
|
||||||
|
// The buyer and the escalator are refunded SEPARATELY, off mischiefOutlay. They
|
||||||
|
// are two people who each parted with their own money, and c.Paid is the sum.
|
||||||
|
func (p *AdventurePlugin) fizzleMischief(c *mischiefContract) {
|
||||||
|
if !fizzleMischiefContract(c.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buyerPaid, escPaid := mischiefOutlay(c)
|
||||||
|
|
||||||
|
refund, rake := p.rakedMischiefRefund(c.BuyerID, buyerPaid, "mischief_fizzle_refund")
|
||||||
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
|
"😾 Your **%s** got to the dungeon and found nobody home — **%s** was already out of there.\n"+
|
||||||
|
"You're refunded %s. The other %s stays with the town, for its trouble.",
|
||||||
|
c.Tier, p.DisplayName(c.TargetID), fmtEuro(refund), fmtEuro(rake)))
|
||||||
|
|
||||||
|
if escPaid > 0 {
|
||||||
|
escRefund, escRake := p.rakedMischiefRefund(c.EscalatedBy, escPaid, "mischief_fizzle_refund")
|
||||||
|
p.SendDM(c.EscalatedBy, fmt.Sprintf(
|
||||||
|
"😾 The **%s** you paid to upgrade found nobody home — **%s** was already out of there.\n"+
|
||||||
|
"You're refunded %s. The other %s stays with the town, for its trouble.",
|
||||||
|
c.Tier, p.DisplayName(c.TargetID), fmtEuro(escRefund), fmtEuro(escRake)))
|
||||||
|
refund += escRefund
|
||||||
|
}
|
||||||
|
emitMischiefFizzledNews(c, refund)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rakedMischiefRefund gives one stakeholder their stake back minus the fizzle
|
||||||
|
// rake, and sinks the rake against THEIR name. Returns what they got back and
|
||||||
|
// what the town kept.
|
||||||
|
func (p *AdventurePlugin) rakedMischiefRefund(who id.UserID, paid int, reason string) (refund, rake int) {
|
||||||
|
if paid <= 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
refund = int(float64(paid) * mischiefFizzleRefund)
|
||||||
|
rake = paid - refund
|
||||||
|
if refund > 0 {
|
||||||
|
p.euro.Credit(who, float64(refund), reason)
|
||||||
|
}
|
||||||
|
if rake > 0 {
|
||||||
|
communityPotAdd(rake)
|
||||||
|
trackTaxPaid(who, rake)
|
||||||
|
}
|
||||||
|
return refund, rake
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverMischief runs the purchased fight and closes it out. By the time this
|
||||||
|
// is called the contract is claimed, so it resolves exactly once.
|
||||||
|
func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition) error {
|
||||||
|
tier, ok := mischiefTierByKey(c.Tier)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown mischief tier %q", c.Tier)
|
||||||
|
}
|
||||||
|
target := c.TargetID
|
||||||
|
|
||||||
|
dndChar, err := LoadDnDCharacter(target)
|
||||||
|
if err != nil || dndChar == nil {
|
||||||
|
// The target evaporated between claim and delivery. Nothing to attack —
|
||||||
|
// treat it as a fizzle so the buyer isn't charged for a no-show.
|
||||||
|
p.refundClaimedMischief(c, "target has no character")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attacker comes from the target's own bracket, not the dungeon they are
|
||||||
|
// standing in. See mischiefBracketZones.
|
||||||
|
bracket := mischiefBracketZone(dndChar.Level)
|
||||||
|
rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
|
||||||
|
chain, ambush := mischiefMonsters(tier.Key, bracket, rng)
|
||||||
|
// Every link, not just the first: a zone-pool entry with no bestiary row
|
||||||
|
// yields a zero-value template, and a nameless 0-HP monster as fight 2 of a
|
||||||
|
// mob would read as the attack inexplicably giving up halfway.
|
||||||
|
if len(chain) == 0 {
|
||||||
|
p.refundClaimedMischief(c, "no monster available")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, m := range chain {
|
||||||
|
if m.Name == "" {
|
||||||
|
p.refundClaimedMischief(c, "bestiary miss in the "+bracket.Display+" pool")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
narration, monsterName, survived := p.runMischiefInterrupt(
|
||||||
|
target, exp, bracket, chain, ambush, c.BlessingCount)
|
||||||
|
|
||||||
|
if survived {
|
||||||
|
p.resolveMischiefSurvived(c, tier, exp, bracket, monsterName, narration)
|
||||||
|
} else {
|
||||||
|
p.resolveMischiefDowned(c, exp, monsterName, narration)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundClaimedMischief unwinds a contract that was already claimed for delivery
|
||||||
|
// but couldn't be staged. Everyone who paid gets everything back — this is our
|
||||||
|
// fault, not a fizzle they gambled on. The escalator is a payer too; see
|
||||||
|
// mischiefOutlay.
|
||||||
|
func (p *AdventurePlugin) refundClaimedMischief(c *mischiefContract, reason string) {
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusFizzled, mischiefStatusFizzled)
|
||||||
|
buyerPaid, escPaid := mischiefOutlay(c)
|
||||||
|
p.euro.Credit(c.BuyerID, float64(buyerPaid), "mischief_refund_unstageable")
|
||||||
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
|
"😾 Your **%s** never made it out of the gate. Fully refunded: %s.", c.Tier, fmtEuro(buyerPaid)))
|
||||||
|
if escPaid > 0 {
|
||||||
|
p.euro.Credit(c.EscalatedBy, float64(escPaid), "mischief_refund_unstageable")
|
||||||
|
p.SendDM(c.EscalatedBy, fmt.Sprintf(
|
||||||
|
"😾 The **%s** you paid to upgrade never made it out of the gate. Fully refunded: %s.",
|
||||||
|
c.Tier, fmtEuro(escPaid)))
|
||||||
|
}
|
||||||
|
slog.Warn("mischief: contract unstageable", "contract", c.ID, "reason", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMischiefInterrupt fights the whole chain and reports whether the target was
|
||||||
|
// still standing at the end.
|
||||||
|
//
|
||||||
|
// Survival is read off the target's HP, not off PlayerWon. The engine's timeout
|
||||||
|
// is a retreat, not a lethal blow — a target who ran out the clock with HP left
|
||||||
|
// held the thing off, and a bought monster that merely *outlasted* them has not
|
||||||
|
// earned a maiming. The chain continues while they stand: a mob is three
|
||||||
|
// attackers, and beating the first two doesn't mean the third goes home.
|
||||||
|
func (p *AdventurePlugin) runMischiefInterrupt(
|
||||||
|
target id.UserID,
|
||||||
|
exp *Expedition,
|
||||||
|
bracket ZoneDefinition,
|
||||||
|
chain []DnDMonsterTemplate,
|
||||||
|
ambush bool,
|
||||||
|
blessings int,
|
||||||
|
) (narration, monsterName string, survived bool) {
|
||||||
|
var b strings.Builder
|
||||||
|
last := chain[len(chain)-1].Name
|
||||||
|
|
||||||
|
if ward := p.applyMischiefBlessings(target, blessings); ward > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
"_The town's wards hold — %d of them, worth %d HP of cushion._\n", blessings, ward))
|
||||||
|
defer p.clearMischiefBlessings(target, ward)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, monster := range chain {
|
||||||
|
// The ambush is the attacker's privilege — it was lying in wait, and the
|
||||||
|
// target had no reason to expect it. Same one-free-swing approximation the
|
||||||
|
// harvest interrupt uses, with the same wounded-entrant clamp so it can't
|
||||||
|
// pre-empt the fight outright.
|
||||||
|
if ambush && i == 0 {
|
||||||
|
if dc, _ := LoadDnDCharacter(target); dc != nil {
|
||||||
|
nick := clampSurpriseNick(
|
||||||
|
surpriseRoundNick(monster, int(bracket.Tier)), dc.HPCurrent, dc.HPMax)
|
||||||
|
if nick > 0 {
|
||||||
|
dc.HPCurrent -= nick
|
||||||
|
_ = SaveDnDCharacter(dc)
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
"_It was waiting for you. **%s** opens with a free swing — %d HP._\n",
|
||||||
|
monster.Name, nick))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preHP, _ := dndHPSnapshot(target)
|
||||||
|
pres, _, err := p.runZoneCombatRoster(
|
||||||
|
fightRoster(target), monster, int(bracket.Tier), dungeonCombatPhases, exp.DMMood)
|
||||||
|
if err != nil {
|
||||||
|
// Mid-chain build failure. The target keeps whatever HP they had; treat
|
||||||
|
// them as having survived rather than maiming them on our bug.
|
||||||
|
slog.Error("mischief: combat error", "target", target, "err", err)
|
||||||
|
b.WriteString(fmt.Sprintf("_(The %s never quite arrives.)_\n", monster.Name))
|
||||||
|
return b.String(), last, true
|
||||||
|
}
|
||||||
|
postHP, maxHP := dndHPSnapshot(target)
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||||
|
switch {
|
||||||
|
case pres.Seats[0].PlayerWon:
|
||||||
|
b.WriteString(fmt.Sprintf("✅ **%s** down — %d→%d / %d HP.\n",
|
||||||
|
monster.Name, preHP, postHP, maxHP))
|
||||||
|
case postHP > 0:
|
||||||
|
// The engine's timeout: they held it off without killing it. That still
|
||||||
|
// pays — but say so plainly, or a stalemate reads as a clean kill and the
|
||||||
|
// player wonders why the thing is still following them.
|
||||||
|
b.WriteString(fmt.Sprintf("⏳ **%s** breaks off, still breathing. You're at %d / %d HP.\n",
|
||||||
|
monster.Name, postHP, maxHP))
|
||||||
|
}
|
||||||
|
if postHP <= 0 {
|
||||||
|
return b.String(), monster.Name, false
|
||||||
|
}
|
||||||
|
if i < len(chain)-1 {
|
||||||
|
b.WriteString("_And it isn't alone._\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String(), last, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMischiefBlessings turns the room's wards into temp HP on the target's
|
||||||
|
// sheet for the duration of the delivery, and returns the cushion granted.
|
||||||
|
//
|
||||||
|
// TempHP is the well-rested mechanism (applyDnDHPScaling layers it above MaxHP
|
||||||
|
// for the fight, and persistDnDHPAfterCombat clamps back down to MaxHP after), so
|
||||||
|
// a ward is a damage sponge and nothing more — it can't leave the target at more
|
||||||
|
// HP than they started with.
|
||||||
|
//
|
||||||
|
// It ADDS to whatever TempHP is already there rather than overwriting: a target
|
||||||
|
// who long-rested at a T3 home before setting out is carrying a cushion of their
|
||||||
|
// own, and a ward bought to help them must not silently delete it. clearMischiefBlessings
|
||||||
|
// subtracts exactly what we added, which is why this returns the amount.
|
||||||
|
//
|
||||||
|
// The cushion refreshes for each link of a chain, because TempHP is a sheet field
|
||||||
|
// and applyDnDHPScaling re-reads it per fight. That only ever matters for the mob
|
||||||
|
// tier (the sole multi-fight chain), which the M0 sweep priced at 98-100% survival
|
||||||
|
// as pure theatre anyway. The tiers where a ward decides anything — elite and boss —
|
||||||
|
// are single fights, so what the room buys there is exactly one sponge.
|
||||||
|
func (p *AdventurePlugin) applyMischiefBlessings(target id.UserID, blessings int) int {
|
||||||
|
if blessings <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
c, err := LoadDnDCharacter(target)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
ward := mischiefBlessingTempHP(c.HPMax, blessings)
|
||||||
|
if ward <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
c.TempHP += ward
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return ward
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearMischiefBlessings takes the ward back off the sheet once the delivery is
|
||||||
|
// over — the blessing was bought for THIS fight, not for the rest of the run.
|
||||||
|
// Subtracts what we added, so a pre-existing well-rested cushion survives intact.
|
||||||
|
func (p *AdventurePlugin) clearMischiefBlessings(target id.UserID, ward int) {
|
||||||
|
c, err := LoadDnDCharacter(target)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.TempHP -= ward; c.TempHP < 0 {
|
||||||
|
c.TempHP = 0
|
||||||
|
}
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// floorMischiefRoster raises every seat the delivery put on the floor back to
|
||||||
|
// 1 HP. It runs on BOTH outcomes, and that is not belt-and-braces: the leader
|
||||||
|
// can win a fight their friend went down in, and a mischief delivery
|
||||||
|
// deliberately skips closeOutZoneWin/closeOutZoneLoss (which is what normally
|
||||||
|
// marks a 0-HP seat dead). Without this, a dropped party member is left alive at
|
||||||
|
// 0 HP — a state nothing else in the game produces, and one the gates that read
|
||||||
|
// `HPCurrent <= 0` (expedition autorun, for one) treat as broken rather than
|
||||||
|
// dead. Nobody dies for money; nobody gets stuck at zero for it either.
|
||||||
|
func (p *AdventurePlugin) floorMischiefRoster(target id.UserID) {
|
||||||
|
for _, uid := range fightRoster(target) {
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
continue // he takes no wounds home; he has no rows to floor
|
||||||
|
}
|
||||||
|
floorHPAtOne(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMischiefSurvived pays the target and unseals the buyer.
|
||||||
|
//
|
||||||
|
// The purse is a percentage of the base fee — never of what the buyer paid — so
|
||||||
|
// it is always strictly less than the buyer's outlay. See mischiefPurse.
|
||||||
|
func (p *AdventurePlugin) resolveMischiefSurvived(
|
||||||
|
c *mischiefContract, tier mischiefTier, exp *Expedition, bracket ZoneDefinition,
|
||||||
|
monsterName, narration string,
|
||||||
|
) {
|
||||||
|
// The leader lived; a friend of theirs may not have. See floorMischiefRoster.
|
||||||
|
p.floorMischiefRoster(c.TargetID)
|
||||||
|
|
||||||
|
purse := mischiefPurse(c.Fee, tier.PayoutPct)
|
||||||
|
p.euro.Credit(c.TargetID, float64(purse), "mischief_survived")
|
||||||
|
|
||||||
|
// Everything spent on the contract that isn't the purse — the rake, plus the
|
||||||
|
// whole sign surcharge — is a sink. That is what makes the payout cap bite. It
|
||||||
|
// is booked against the buyer AND the escalator in proportion to what each of
|
||||||
|
// them actually put in; c.Paid is the sum of the two.
|
||||||
|
if rake := c.Paid - purse; rake > 0 {
|
||||||
|
communityPotAdd(rake)
|
||||||
|
trackMischiefSink(c, rake)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extras by tier. Treasure rolls against the zone they're actually in — the
|
||||||
|
// loot is scavenged off a corpse in that dungeon, wherever the thing came from.
|
||||||
|
var extras []string
|
||||||
|
switch tier.Key {
|
||||||
|
case "mob":
|
||||||
|
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||||
|
case "elite":
|
||||||
|
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||||
|
if before, after, err := addRenownXP(c.TargetID, mischiefEliteRenownXP); err == nil {
|
||||||
|
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
|
||||||
|
extras = append(extras, "The story of it is already going round town.")
|
||||||
|
}
|
||||||
|
case "boss":
|
||||||
|
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||||
|
if before, after, err := addRenownXP(c.TargetID, mischiefBossRenownXP); err == nil {
|
||||||
|
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
|
||||||
|
extras = append(extras, "People are going to be telling this one for a while.")
|
||||||
|
}
|
||||||
|
for _, item := range consumableCache(int(bracket.Tier), mischiefBossCacheSize) {
|
||||||
|
it := item
|
||||||
|
p.grantZoneItem(c.TargetID, &it, "🧪")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
|
||||||
|
var dm strings.Builder
|
||||||
|
dm.WriteString("😈 **Somebody paid for that.**\n\n")
|
||||||
|
dm.WriteString(narration)
|
||||||
|
dm.WriteString(fmt.Sprintf(
|
||||||
|
"\n🛡️ You're still standing, and the coin was never really theirs. **%s** is yours.\n",
|
||||||
|
fmtEuro(purse)))
|
||||||
|
dm.WriteString(fmt.Sprintf("It was **%s** who paid to have you killed. Do with that what you like.%s",
|
||||||
|
p.DisplayName(c.BuyerID), p.mischiefEscalatorNote(c)))
|
||||||
|
for _, e := range extras {
|
||||||
|
dm.WriteString("\n_" + e + "_")
|
||||||
|
}
|
||||||
|
p.SendDM(c.TargetID, dm.String())
|
||||||
|
|
||||||
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
|
"🛡️ **%s walked away from your %s.** They keep %s of your money, and the town knows your name now.",
|
||||||
|
p.DisplayName(c.TargetID), c.Tier, fmtEuro(purse)))
|
||||||
|
if c.EscalatedBy != "" && c.EscalatedBy != c.BuyerID {
|
||||||
|
p.SendDM(c.EscalatedBy, fmt.Sprintf(
|
||||||
|
"🛡️ **%s** walked away from the **%s** you paid to upgrade — and your money went into their purse.\n"+
|
||||||
|
"_The town knows you piled on. That was the deal._",
|
||||||
|
p.DisplayName(c.TargetID), c.Tier))
|
||||||
|
}
|
||||||
|
|
||||||
|
p.announceMischiefSurvived(c, monsterName, purse)
|
||||||
|
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeSurvived, purse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMischiefDowned maims the target: HP floored at 1, expedition
|
||||||
|
// force-extracted, un-banked coins docked. Nobody dies for money.
|
||||||
|
//
|
||||||
|
// The whole fee goes to the community pot — never back to the buyer. A
|
||||||
|
// successful hit buys glory, not a rebate; refunding it would make repeat
|
||||||
|
// contracts free and turn the cap into a rounding error.
|
||||||
|
func (p *AdventurePlugin) resolveMischiefDowned(
|
||||||
|
c *mischiefContract, exp *Expedition, monsterName, narration string,
|
||||||
|
) {
|
||||||
|
// Every seat, not just the leader: a party that went down with them went down
|
||||||
|
// to a bought monster too, and the no-perma-death rule is about the purchase,
|
||||||
|
// not about who was holding the torch. Must run BEFORE the extract, which
|
||||||
|
// releases the party and would leave us nobody to floor.
|
||||||
|
p.floorMischiefRoster(c.TargetID)
|
||||||
|
|
||||||
|
// forcedExtractExpedition retires the region runs and releases the party itself;
|
||||||
|
// only the zone run has to be closed here.
|
||||||
|
_ = abandonZoneRun(c.TargetID)
|
||||||
|
_, tax, err := forcedExtractExpedition(exp.ID, "mischief contract")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("mischief: force extract failed", "expedition", exp.ID, "err", err)
|
||||||
|
}
|
||||||
|
// The un-banked-coin loss. forcedExtractExpedition computes the 20% cut and
|
||||||
|
// leaves the debit to the caller; for a maiming, this is the caller.
|
||||||
|
if tax > 0 {
|
||||||
|
p.euro.Debit(c.TargetID, float64(tax), "mischief_downed_loss")
|
||||||
|
}
|
||||||
|
|
||||||
|
communityPotAdd(c.Paid)
|
||||||
|
trackMischiefSink(c, c.Paid)
|
||||||
|
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
|
||||||
|
|
||||||
|
var dm strings.Builder
|
||||||
|
dm.WriteString("😈 **Somebody paid for that.**\n\n")
|
||||||
|
dm.WriteString(narration)
|
||||||
|
dm.WriteString(fmt.Sprintf(
|
||||||
|
"\n💀 **%s** put you down. You wake up on a cart headed home — alive, which is more than the contract asked for.\n",
|
||||||
|
monsterName))
|
||||||
|
dm.WriteString("Your expedition is over.")
|
||||||
|
if tax > 0 {
|
||||||
|
dm.WriteString(fmt.Sprintf(" Whatever you hadn't banked yet is gone — %s of it.", fmtEuro(tax)))
|
||||||
|
}
|
||||||
|
if c.Signed {
|
||||||
|
dm.WriteString(fmt.Sprintf("\n\nThe contract was signed: **%s** paid for it.", p.DisplayName(c.BuyerID)))
|
||||||
|
} else {
|
||||||
|
dm.WriteString("\n\nNobody's saying who paid.")
|
||||||
|
}
|
||||||
|
p.SendDM(c.TargetID, dm.String())
|
||||||
|
|
||||||
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
|
"💀 Your **%s** found **%s** and put them on the floor. Their expedition is over.\n"+
|
||||||
|
"_You get nothing back — %s went to the community pot. You did this for the story._",
|
||||||
|
c.Tier, p.DisplayName(c.TargetID), fmtEuro(c.Paid)))
|
||||||
|
if c.EscalatedBy != "" && c.EscalatedBy != c.BuyerID {
|
||||||
|
// They stay anonymous: only a survival unseals. Their money is gone either
|
||||||
|
// way — that is what makes the survival unseal a real risk to have taken.
|
||||||
|
p.SendDM(c.EscalatedBy, fmt.Sprintf(
|
||||||
|
"💀 The **%s** you paid to upgrade found **%s** and put them down. Nobody knows it was you.",
|
||||||
|
c.Tier, p.DisplayName(c.TargetID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
p.announceMischiefDowned(c, monsterName)
|
||||||
|
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeDowned, 0)
|
||||||
|
}
|
||||||
929
internal/plugin/adventure_mischief_test.go
Normal file
929
internal/plugin/adventure_mischief_test.go
Normal file
@@ -0,0 +1,929 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMischiefTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedContract(t *testing.T, buyer, target id.UserID, tier string, signed bool) *mischiefContract {
|
||||||
|
t.Helper()
|
||||||
|
td, ok := mischiefTierByKey(tier)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unknown tier %q", tier)
|
||||||
|
}
|
||||||
|
paid := td.Fee
|
||||||
|
if signed {
|
||||||
|
paid = mischiefSignedFee(td.Fee)
|
||||||
|
}
|
||||||
|
c := &mischiefContract{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
BuyerID: buyer,
|
||||||
|
TargetID: target,
|
||||||
|
Tier: td.Key,
|
||||||
|
Fee: td.Fee,
|
||||||
|
Paid: paid,
|
||||||
|
Status: mischiefStatusOpen,
|
||||||
|
Signed: signed,
|
||||||
|
Source: "matrix",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
|
||||||
|
}
|
||||||
|
if err := insertMischiefContract(c); err != nil {
|
||||||
|
t.Fatalf("seed contract (%s → %s): %v", buyer, target, err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// The anti-collusion invariant, and the only reason no danger multiplier is
|
||||||
|
// needed: whatever the tier and whether or not the buyer signed, the target's
|
||||||
|
// survival purse is strictly less than what the buyer parted with. Two players
|
||||||
|
// who try to move money this way lose to !baltransfer, which is free.
|
||||||
|
func TestMischiefPurseNeverExceedsOutlay(t *testing.T) {
|
||||||
|
for _, tier := range mischiefTiers {
|
||||||
|
purse := mischiefPurse(tier.Fee, tier.PayoutPct)
|
||||||
|
for _, paid := range []int{tier.Fee, mischiefSignedFee(tier.Fee)} {
|
||||||
|
if purse >= paid {
|
||||||
|
t.Errorf("%s: purse %d >= buyer outlay %d — collusion beats !baltransfer",
|
||||||
|
tier.Key, purse, paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tier.PayoutPct > 0.75 {
|
||||||
|
t.Errorf("%s: payout %.2f exceeds the 75%% cap", tier.Key, tier.PayoutPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cap is enforced in code, not just in the table — an escalation (M2) raises
|
||||||
|
// the payout basis, and nothing it can do may push the purse past 75%.
|
||||||
|
func TestMischiefPurseHardCap(t *testing.T) {
|
||||||
|
if got := mischiefPurse(1000, 0.95); got != 750 {
|
||||||
|
t.Fatalf("mischiefPurse(1000, 0.95) = %d, want 750 (capped)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefSignedFee(t *testing.T) {
|
||||||
|
// The tier table's signed prices, as priced in M0.
|
||||||
|
want := map[string]int{"grunt": 50, "mob": 125, "elite": 438, "boss": 1500}
|
||||||
|
for _, tier := range mischiefTiers {
|
||||||
|
if got := mischiefSignedFee(tier.Fee); got != want[tier.Key] {
|
||||||
|
t.Errorf("%s signed fee = %d, want %d", tier.Key, got, want[tier.Key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one of delivery and fizzle may claim a contract, however many ticks
|
||||||
|
// race for it. Without this a restart mid-delivery could both maim the target
|
||||||
|
// and refund the buyer.
|
||||||
|
func TestMischiefClaimIsExactlyOnce(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
c := seedContract(t, "@buyer:x", "@target:x", "elite", false)
|
||||||
|
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("first claim should win")
|
||||||
|
}
|
||||||
|
if claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Error("second claim won — a contract could be delivered twice")
|
||||||
|
}
|
||||||
|
if fizzleMischiefContract(c.ID) {
|
||||||
|
t.Error("fizzle won after the delivery claim — buyer would be refunded a hit that landed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false)
|
||||||
|
if !fizzleMischiefContract(c2.ID) {
|
||||||
|
t.Fatal("first fizzle should win")
|
||||||
|
}
|
||||||
|
if claimMischiefForDelivery(c2.ID) {
|
||||||
|
t.Error("delivery claimed a fizzled contract — buyer would pay twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a closed window is due, and a resolved contract never comes back.
|
||||||
|
func TestMischiefDueWindow(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c := seedContract(t, "@buyer:x", "@target:x", "mob", false)
|
||||||
|
|
||||||
|
if due := loadMischiefDue(now); len(due) != 0 {
|
||||||
|
t.Fatalf("contract is due before its window closed: %d", len(due))
|
||||||
|
}
|
||||||
|
due := loadMischiefDue(now.Add(mischiefWindow + time.Minute))
|
||||||
|
if len(due) != 1 || due[0].ID != c.ID {
|
||||||
|
t.Fatalf("want the contract due after its window; got %d", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
if due := loadMischiefDue(now.Add(2 * mischiefWindow)); len(due) != 0 {
|
||||||
|
t.Error("a resolved contract came back around")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One live contract per target: two monsters converging on the same person is a
|
||||||
|
// mugging, not mischief.
|
||||||
|
func TestMischiefOneLiveContractPerTarget(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
target := id.UserID("@target:x")
|
||||||
|
if liveMischiefForTarget(target) != nil {
|
||||||
|
t.Fatal("no contract seeded, but one is live")
|
||||||
|
}
|
||||||
|
c := seedContract(t, "@buyer:x", target, "grunt", false)
|
||||||
|
if live := liveMischiefForTarget(target); live == nil || live.ID != c.ID {
|
||||||
|
t.Fatal("open contract should be live")
|
||||||
|
}
|
||||||
|
// A claimed-but-unresolved contract still blocks — the monster is en route.
|
||||||
|
claimMischiefForDelivery(c.ID)
|
||||||
|
if liveMischiefForTarget(target) == nil {
|
||||||
|
t.Error("a delivering contract should still block a second one")
|
||||||
|
}
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
|
||||||
|
if liveMischiefForTarget(target) != nil {
|
||||||
|
t.Error("a resolved contract still reads as live")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The post-resolution breather. Also the regression guard for the modernc
|
||||||
|
// datetime hazard: resolved_at round-trips through the driver's own encoding
|
||||||
|
// (bound Go time in, time.Time out), so the row has to be a real one — a
|
||||||
|
// hand-written stamp in some other format would compare lexicographically and
|
||||||
|
// silently never expire.
|
||||||
|
func TestMischiefTargetCooldown(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
target := id.UserID("@target:x")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
if cooling, _ := mischiefTargetCoolingDown(target, now); cooling {
|
||||||
|
t.Fatal("a target with no history is cooling down")
|
||||||
|
}
|
||||||
|
c := seedContract(t, "@buyer:x", target, "grunt", false)
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
|
||||||
|
cooling, wait := mischiefTargetCoolingDown(target, now)
|
||||||
|
if !cooling {
|
||||||
|
t.Fatal("a just-resolved target should be cooling down")
|
||||||
|
}
|
||||||
|
// resolved_at is stamped a hair after `now`, so allow a second of slack.
|
||||||
|
if wait <= 0 || wait > mischiefTargetCooldown+time.Second {
|
||||||
|
t.Errorf("wait = %s, want within (0, %s]", wait, mischiefTargetCooldown)
|
||||||
|
}
|
||||||
|
if cooling, _ := mischiefTargetCoolingDown(target, now.Add(mischiefTargetCooldown+time.Minute)); cooling {
|
||||||
|
t.Error("still cooling down after the cooldown elapsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limits: contracts per buyer per day, and boss-tier per target per week.
|
||||||
|
// Fizzled contracts count against the buyer — the limit is on how often you may
|
||||||
|
// point a monster at someone, not on how often it connects.
|
||||||
|
func TestMischiefRateLimits(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
buyer := id.UserID("@buyer:x")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
dayAgo := now.Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
if n := mischiefBuyerCountSince(buyer, dayAgo); n != 0 {
|
||||||
|
t.Fatalf("fresh buyer count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
c1 := seedContract(t, buyer, "@a:x", "grunt", false)
|
||||||
|
seedContract(t, buyer, "@b:x", "mob", false)
|
||||||
|
fizzleMischiefContract(c1.ID)
|
||||||
|
if n := mischiefBuyerCountSince(buyer, dayAgo); n != mischiefBuyerDailyCap {
|
||||||
|
t.Errorf("buyer count = %d, want %d (a fizzle still counts)", n, mischiefBuyerDailyCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := id.UserID("@whale:x")
|
||||||
|
weekAgo := now.Add(-mischiefBossPerTargetWindow)
|
||||||
|
if n := mischiefBossCountSince(target, weekAgo); n != 0 {
|
||||||
|
t.Fatalf("fresh boss count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
elite := seedContract(t, "@other:x", target, "elite", false)
|
||||||
|
if n := mischiefBossCountSince(target, weekAgo); n != 0 {
|
||||||
|
t.Error("an elite contract counted against the boss-per-week limit")
|
||||||
|
}
|
||||||
|
// Resolve it first: only one contract may be live against a target at a time,
|
||||||
|
// and the database enforces that (idx_mischief_one_live_per_target).
|
||||||
|
resolveMischiefContract(elite.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
seedContract(t, "@other:x", target, "boss", false)
|
||||||
|
if n := mischiefBossCountSince(target, weekAgo); n != 1 {
|
||||||
|
t.Errorf("boss count = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The database, not a read-then-write check, is what stops two buyers racing a
|
||||||
|
// monster at the same target. The placement path holds only the buyer's lock, so
|
||||||
|
// without this index both inserts would land and the target would be hit twice.
|
||||||
|
func TestMischiefSecondLiveContractIsRejected(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
target := id.UserID("@target:x")
|
||||||
|
seedContract(t, "@buyer1:x", target, "grunt", false)
|
||||||
|
|
||||||
|
second := &mischiefContract{
|
||||||
|
ID: uuid.NewString(), BuyerID: "@buyer2:x", TargetID: target,
|
||||||
|
Tier: "elite", Fee: 350, Paid: 350, Status: mischiefStatusOpen,
|
||||||
|
Source: "matrix", CreatedAt: time.Now().UTC(),
|
||||||
|
WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
|
||||||
|
}
|
||||||
|
if err := insertMischiefContract(second); err == nil {
|
||||||
|
t.Fatal("a second live contract was accepted — the target can be hit twice at once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delivery that dies mid-flight (restart, panic) must not strand the contract:
|
||||||
|
// the row would block the target forever and the buyer's money would be gone.
|
||||||
|
func TestMischiefStaleDeliverySweep(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c := seedContract(t, "@buyer:x", "@target:x", "boss", false)
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside the grace period a slow delivery is left alone.
|
||||||
|
if stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow)); len(stale) != 0 {
|
||||||
|
t.Errorf("a delivery still inside its grace window was called stranded: %d", len(stale))
|
||||||
|
}
|
||||||
|
|
||||||
|
stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow + mischiefDeliveryGrace + time.Minute))
|
||||||
|
if len(stale) != 1 || stale[0].ID != c.ID {
|
||||||
|
t.Fatalf("stranded delivery not found: %d", len(stale))
|
||||||
|
}
|
||||||
|
if !abandonStaleMischief(c.ID) {
|
||||||
|
t.Fatal("abandon should win on a delivering row")
|
||||||
|
}
|
||||||
|
if abandonStaleMischief(c.ID) {
|
||||||
|
t.Error("abandon won twice — the buyer would be refunded twice")
|
||||||
|
}
|
||||||
|
// And the target is free again.
|
||||||
|
if liveMischiefForTarget("@target:x") != nil {
|
||||||
|
t.Error("a swept contract still blocks the target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The monster is priced against the TARGET's bracket, not the dungeon they are
|
||||||
|
// standing in — that is what the M0 fee table measured.
|
||||||
|
func TestMischiefBracketZones(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
level int
|
||||||
|
tier ZoneTier
|
||||||
|
}{{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3}, {13, 4}, {17, 4}, {18, 5}, {20, 5}} {
|
||||||
|
zone := mischiefBracketZone(tc.level)
|
||||||
|
if zone.Tier != tc.tier {
|
||||||
|
t.Errorf("level %d draws from tier %d, want %d", tc.level, zone.Tier, tc.tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fight shapes the fee table was priced against. Changing any of these
|
||||||
|
// invalidates the pricing, so they are pinned.
|
||||||
|
func TestMischiefMonsterChains(t *testing.T) {
|
||||||
|
zone := mischiefBracketZone(10) // tier 3
|
||||||
|
rng := rand.New(rand.NewPCG(0x6d69, 1))
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
tier string
|
||||||
|
wantLen int
|
||||||
|
wantAmbush bool
|
||||||
|
}{
|
||||||
|
{"grunt", 1, false},
|
||||||
|
{"mob", 3, false},
|
||||||
|
{"elite", 1, true},
|
||||||
|
{"boss", 1, true},
|
||||||
|
} {
|
||||||
|
chain, ambush := mischiefMonsters(tc.tier, zone, rng)
|
||||||
|
if len(chain) != tc.wantLen {
|
||||||
|
t.Errorf("%s: chain of %d, want %d", tc.tier, len(chain), tc.wantLen)
|
||||||
|
}
|
||||||
|
if ambush != tc.wantAmbush {
|
||||||
|
t.Errorf("%s: ambush = %v, want %v", tc.tier, ambush, tc.wantAmbush)
|
||||||
|
}
|
||||||
|
for _, m := range chain {
|
||||||
|
if m.Name == "" {
|
||||||
|
t.Errorf("%s: chain holds an empty monster — the bestiary lookup missed", tc.tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss tier sends the zone's actual boss; elite sends something flagged elite.
|
||||||
|
if chain, _ := mischiefMonsters("boss", zone, rng); chain[0].ID != zone.Boss.BestiaryID {
|
||||||
|
t.Errorf("boss tier sent %q, want the zone boss %q", chain[0].ID, zone.Boss.BestiaryID)
|
||||||
|
}
|
||||||
|
elite, _ := mischiefMonsters("elite", zone, rng)
|
||||||
|
found := false
|
||||||
|
for _, e := range zone.Enemies {
|
||||||
|
if e.BestiaryID == elite[0].ID && e.IsElite {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("elite tier sent %q, which isn't an elite in %s", elite[0].ID, zone.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── End-to-end: the delivery path against a real character ───────────────────
|
||||||
|
|
||||||
|
// seedMischiefTarget builds a real, playable adventurer on a live expedition —
|
||||||
|
// the thing a contract actually lands on. Unit tests on the contract row prove
|
||||||
|
// nothing about the fight; these drive runMischiefInterrupt and the close-outs.
|
||||||
|
func seedMischiefTarget(t *testing.T, uid id.UserID, level, hp int) *Expedition {
|
||||||
|
t.Helper()
|
||||||
|
if err := createAdvCharacter(uid, "target"+uid.Localpart()); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(&DnDCharacter{
|
||||||
|
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||||||
|
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||||
|
HPMax: hp, HPCurrent: hp, ArmorClass: 18,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||||
|
}
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startZoneRun: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, coins_earned)
|
||||||
|
VALUES (?, ?, 'goblin_warrens', ?, 'active', ?, 500)`,
|
||||||
|
"exp-"+uid.Localpart(), string(uid), run.RunID, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("seed expedition: %v", err)
|
||||||
|
}
|
||||||
|
exp, err := getActiveExpedition(uid)
|
||||||
|
if err != nil || exp == nil {
|
||||||
|
t.Fatalf("getActiveExpedition: %v", err)
|
||||||
|
}
|
||||||
|
return exp
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grunt against a healthy level-5 fighter: the fight actually runs, it costs
|
||||||
|
// HP, and they live. This is the "mostly theatre" tier doing what it was priced
|
||||||
|
// to do.
|
||||||
|
func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid := id.UserID("@grunted:example.org")
|
||||||
|
exp := seedMischiefTarget(t, uid, 5, 60)
|
||||||
|
|
||||||
|
bracket := mischiefBracketZone(5)
|
||||||
|
chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2)))
|
||||||
|
|
||||||
|
_, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush, 0)
|
||||||
|
if !survived {
|
||||||
|
t.Fatalf("a level-5 fighter at full HP died to a grunt (%s) — the tier is mispriced", monster)
|
||||||
|
}
|
||||||
|
hp, _ := dndHPSnapshot(uid)
|
||||||
|
if hp <= 0 {
|
||||||
|
t.Errorf("survivor is at %d HP", hp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The boss tier is the dangerous one, and the one where the no-perma-death rule
|
||||||
|
// has to hold. A level-3 caster-grade sheet against a tier-1 boss goes down —
|
||||||
|
// and must come back up at 1 HP with the expedition force-extracted, never dead.
|
||||||
|
//
|
||||||
|
// This is the property that separates mischief from paid murder: it is bought,
|
||||||
|
// and it lands while the victim may well be asleep.
|
||||||
|
func TestMischiefDelivery_DownedTargetIsMaimedNotKilled(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
uid := id.UserID("@doomed:example.org")
|
||||||
|
// 1 HP: whatever the dice do, this fight ends with them on the floor.
|
||||||
|
exp := seedMischiefTarget(t, uid, 3, 1)
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:example.org", uid, "boss", false)
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
if err := p.deliverMischief(c, exp); err != nil {
|
||||||
|
t.Fatalf("deliverMischief: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alive, floored, and carried home.
|
||||||
|
char, err := loadAdvCharacter(uid)
|
||||||
|
if err != nil || char == nil {
|
||||||
|
t.Fatal("target's character is gone")
|
||||||
|
}
|
||||||
|
if !char.Alive {
|
||||||
|
t.Fatal("a PURCHASED monster killed a player outright — mischief must maim, never murder")
|
||||||
|
}
|
||||||
|
if hp, _ := dndHPSnapshot(uid); hp != 1 {
|
||||||
|
t.Errorf("downed target is at %d HP, want the 1 HP floor", hp)
|
||||||
|
}
|
||||||
|
if e, _ := getActiveExpedition(uid); e != nil {
|
||||||
|
t.Error("the expedition survived a downing — it must be force-extracted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The buyer gets nothing back; the whole fee is a sink.
|
||||||
|
got, err := mischiefContractByID(c.ID)
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatal("contract row vanished")
|
||||||
|
}
|
||||||
|
if got.Status != mischiefStatusDelivered || got.Outcome != mischiefOutcomeDowned {
|
||||||
|
t.Errorf("contract resolved as %s/%s, want delivered/downed", got.Status, got.Outcome)
|
||||||
|
}
|
||||||
|
if pot := communityPotBalance(); pot < c.Paid {
|
||||||
|
t.Errorf("community pot holds %d, want at least the %d fee — a landed hit refunds nobody", pot, c.Paid)
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(c.BuyerID); bal > 0 {
|
||||||
|
t.Errorf("buyer was credited %.0f on a successful hit; glory only", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Survival pays the target out of the buyer's money, and never more than the
|
||||||
|
// buyer spent. Driven through the real close-out, not the arithmetic helper.
|
||||||
|
func TestMischiefDelivery_SurvivalPaysTheTarget(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
uid := id.UserID("@tough:example.org")
|
||||||
|
exp := seedMischiefTarget(t, uid, 12, 400) // comfortably survives a tier-1 grunt
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:example.org", uid, "grunt", false)
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
if err := p.deliverMischief(c, exp); err != nil {
|
||||||
|
t.Fatalf("deliverMischief: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Outcome != mischiefOutcomeSurvived {
|
||||||
|
t.Fatalf("a level-12 fighter with 400 HP lost to a tier-1 grunt (outcome %s)", got.Outcome)
|
||||||
|
}
|
||||||
|
tier, _ := mischiefTierByKey("grunt")
|
||||||
|
purse := mischiefPurse(tier.Fee, tier.PayoutPct)
|
||||||
|
if gained := int(p.euro.GetBalance(uid) - before); gained != purse {
|
||||||
|
t.Errorf("survivor was paid %d, want the %d purse", gained, purse)
|
||||||
|
}
|
||||||
|
if purse >= c.Paid {
|
||||||
|
t.Errorf("purse %d >= outlay %d", purse, c.Paid)
|
||||||
|
}
|
||||||
|
// The remainder is a sink, not a rebate.
|
||||||
|
if pot := communityPotBalance(); pot != c.Paid-purse {
|
||||||
|
t.Errorf("pot holds %d, want the %d remainder", pot, c.Paid-purse)
|
||||||
|
}
|
||||||
|
// The expedition is untouched — they fought it off and walk on.
|
||||||
|
if e, _ := getActiveExpedition(uid); e == nil {
|
||||||
|
t.Error("surviving a contract ended the expedition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An expedition that ends before the window closes fizzles: the buyer is
|
||||||
|
// refunded 90%, the town rakes the rest, and nobody is attacked.
|
||||||
|
func TestMischiefDelivery_FizzlesWhenTargetWentHome(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
uid := id.UserID("@gone:example.org")
|
||||||
|
seedMischiefTarget(t, uid, 5, 60)
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:example.org", uid, "elite", false)
|
||||||
|
// They extract before the monster arrives.
|
||||||
|
if _, _, err := forcedExtractExpedition("exp-"+uid.Localpart(), "went home"); err != nil {
|
||||||
|
t.Fatalf("forcedExtractExpedition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tomorrow at noon UTC: past the contract's window, and deliberately nowhere
|
||||||
|
// near the 06:00 briefing or the 21:00 recap. fireMischiefDeliveries refuses to
|
||||||
|
// run inside those quiet windows, so a wall-clock `now` here makes this test
|
||||||
|
// fail for two hours of every real day.
|
||||||
|
due := time.Now().UTC().AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(12 * time.Hour)
|
||||||
|
p.fireMischiefDeliveries(due)
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Status != mischiefStatusFizzled {
|
||||||
|
t.Fatalf("contract status %s, want fizzled — the dungeon was empty", got.Status)
|
||||||
|
}
|
||||||
|
refund := int(float64(c.Paid) * mischiefFizzleRefund)
|
||||||
|
if bal := int(p.euro.GetBalance(c.BuyerID)); bal != refund {
|
||||||
|
t.Errorf("buyer refunded %d, want %d (90%%)", bal, refund)
|
||||||
|
}
|
||||||
|
if pot := communityPotBalance(); pot != c.Paid-refund {
|
||||||
|
t.Errorf("pot raked %d, want %d", pot, c.Paid-refund)
|
||||||
|
}
|
||||||
|
if hp, max := dndHPSnapshot(uid); hp != max {
|
||||||
|
t.Errorf("a fizzled contract still hurt the target (%d/%d HP)", hp, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The limbo state. A leader can win a fight their friend went down in — and a
|
||||||
|
// mischief delivery deliberately skips the close-outs that would normally mark a
|
||||||
|
// 0-HP seat dead. So the seat must be floored on BOTH outcomes, or the member is
|
||||||
|
// left alive at 0 HP: not dead enough for the hospital, too dead to act, and
|
||||||
|
// read as broken by every gate that tests `HPCurrent <= 0`.
|
||||||
|
func TestMischiefDelivery_SurvivalFloorsADroppedPartyMember(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
leader := id.UserID("@leader:example.org")
|
||||||
|
member := id.UserID("@member:example.org")
|
||||||
|
|
||||||
|
exp := seedMischiefTarget(t, leader, 12, 400) // wins comfortably
|
||||||
|
seatLeaderFixture(t, exp.ID)
|
||||||
|
if err := createAdvCharacter(member, "member"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(&DnDCharacter{
|
||||||
|
UserID: member, Race: RaceHuman, Class: ClassFighter, Level: 3,
|
||||||
|
STR: 14, DEX: 12, CON: 12, INT: 10, WIS: 10, CHA: 10,
|
||||||
|
HPMax: 30, HPCurrent: 30, ArmorClass: 12,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := joinParty(exp.ID, member); err != nil {
|
||||||
|
t.Fatalf("joinParty: %v", err)
|
||||||
|
}
|
||||||
|
// Put the member on the floor the way the fight would.
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(member)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:example.org", leader, "grunt", false)
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
if err := p.deliverMischief(c, exp); err != nil {
|
||||||
|
t.Fatalf("deliverMischief: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Outcome != mischiefOutcomeSurvived {
|
||||||
|
t.Fatalf("leader lost; this test needs a survival (outcome %s)", got.Outcome)
|
||||||
|
}
|
||||||
|
hp, _ := dndHPSnapshot(member)
|
||||||
|
if hp <= 0 {
|
||||||
|
t.Errorf("dropped party member left at %d HP on a survived contract — alive, but stuck at zero", hp)
|
||||||
|
}
|
||||||
|
if mc, _ := loadAdvCharacter(member); mc == nil || !mc.Alive {
|
||||||
|
t.Error("a bought monster killed a party member — mischief maims, it never murders")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── M2: the window (bless / escalate) ────────────────────────────────────────
|
||||||
|
|
||||||
|
// mischiefWindowPlugin is a plugin wired for command-level tests: a euro ledger
|
||||||
|
// and a sink, so `!mischief bless/escalate` can be driven the way a player drives
|
||||||
|
// them (the CAS races these commands lose are the whole point of M2, and they only
|
||||||
|
// exist on the command path).
|
||||||
|
func mischiefWindowPlugin(t *testing.T) (*AdventurePlugin, *captureSink) {
|
||||||
|
t.Helper()
|
||||||
|
sink := &captureSink{}
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
p.Sink = sink
|
||||||
|
return p, sink
|
||||||
|
}
|
||||||
|
|
||||||
|
func mischiefCtx(sender id.UserID) MessageContext {
|
||||||
|
return MessageContext{Sender: sender, RoomID: id.RoomID("!room:x"), EventID: id.EventID("$e")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ward is priced against the thing it defends against. The M1-close-out sweep
|
||||||
|
// measured three wards buying ~+40pp of survival, which at a flat fee let the room
|
||||||
|
// halve a €1,200 boss contract for €75 — the tier the whole economy rests on,
|
||||||
|
// bought off for pocket change. Cheap at the impulse tiers, real money at the top.
|
||||||
|
func TestMischiefBlessingFee_ScalesWithTheContract(t *testing.T) {
|
||||||
|
want := map[string]int{"grunt": 25, "mob": 25, "elite": 35, "boss": 120}
|
||||||
|
for _, tier := range mischiefTiers {
|
||||||
|
if got := mischiefBlessingFee(tier.Fee); got != want[tier.Key] {
|
||||||
|
t.Errorf("%s ward = %d, want %d", tier.Key, got, want[tier.Key])
|
||||||
|
}
|
||||||
|
// Where a ward is a rational purchase — the tiers the sweep says can
|
||||||
|
// actually put someone on the floor — covering a target must cost less than
|
||||||
|
// the contract does. Otherwise the room's counterplay is priced out and the
|
||||||
|
// window is dead.
|
||||||
|
//
|
||||||
|
// Grunt and mob are deliberately exempt: the €25 floor is above-market
|
||||||
|
// there (3 wards cost more than a €40 grunt), and that is fine, because
|
||||||
|
// nothing in the sweep needs warding against theatre — those tiers sit at
|
||||||
|
// 100% survival unwounded. The floor is there to keep a ward an impulse
|
||||||
|
// buy, not to make it a sensible one against a goblin.
|
||||||
|
if tier.Key != "elite" && tier.Key != "boss" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if full := mischiefBlessingFee(tier.Fee) * mischiefBlessingCap; full >= tier.Fee {
|
||||||
|
t.Errorf("%s: %d wards cost %d, which is more than the %d contract — "+
|
||||||
|
"the room can't afford to save anyone", tier.Key, mischiefBlessingCap, full, tier.Fee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cap lives in the UPDATE, not in the read that precedes it. A room that
|
||||||
|
// piles four wards on in the same second must still land exactly three — and the
|
||||||
|
// player who lost that race must get their money back rather than pay for
|
||||||
|
// nothing.
|
||||||
|
func TestMischiefBlessing_CapIsEnforcedByTheUpdate(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
c := seedContract(t, "@buyer:x", "@target:x", "elite", false)
|
||||||
|
|
||||||
|
for i := 1; i <= mischiefBlessingCap; i++ {
|
||||||
|
if !blessMischiefContract(c.ID) {
|
||||||
|
t.Fatalf("blessing %d/%d was rejected", i, mischiefBlessingCap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blessMischiefContract(c.ID) {
|
||||||
|
t.Errorf("a %dth blessing landed — the cap is not enforced", mischiefBlessingCap+1)
|
||||||
|
}
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.BlessingCount != mischiefBlessingCap {
|
||||||
|
t.Errorf("blessing_count = %d, want %d", got.BlessingCount, mischiefBlessingCap)
|
||||||
|
}
|
||||||
|
// And nobody may ward a contract that has already been claimed for delivery:
|
||||||
|
// the monster is in the room with them.
|
||||||
|
c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false)
|
||||||
|
if !claimMischiefForDelivery(c2.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
if blessMischiefContract(c2.ID) {
|
||||||
|
t.Error("warded a contract mid-delivery — the fight had already started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blessing buys HP, never euros: the fee is a sink (community pot), and the
|
||||||
|
// purse is untouched by it. Otherwise a target's friend could ward them as a way
|
||||||
|
// of routing money into their pocket on the way out.
|
||||||
|
func TestMischiefBlessing_FeeIsASinkNotAPurseTopUp(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p, _ := mischiefWindowPlugin(t)
|
||||||
|
target := id.UserID("@warded:x")
|
||||||
|
friend := id.UserID("@friend:x")
|
||||||
|
p.euro.Credit(friend, 500, "test")
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:x", target, "elite", false)
|
||||||
|
feeBefore := c.Fee
|
||||||
|
ward := mischiefBlessingFee(c.Fee) // 10% of the €350 elite fee
|
||||||
|
|
||||||
|
if err := p.mischiefBlessCmd(mischiefCtx(friend), []string{string(target)}); err != nil {
|
||||||
|
t.Fatalf("bless: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.BlessingCount != 1 {
|
||||||
|
t.Fatalf("blessing_count = %d, want 1", got.BlessingCount)
|
||||||
|
}
|
||||||
|
if got.Fee != feeBefore {
|
||||||
|
t.Errorf("a blessing moved the payout basis to %d (was %d) — wards must not pay the target",
|
||||||
|
got.Fee, feeBefore)
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(friend); bal != float64(500-ward) {
|
||||||
|
t.Errorf("blesser balance %.0f, want %d", bal, 500-ward)
|
||||||
|
}
|
||||||
|
if pot := communityPotBalance(); pot != ward {
|
||||||
|
t.Errorf("pot holds %d, want the %d ward fee", pot, ward)
|
||||||
|
}
|
||||||
|
// Broke, so the second ward can't be bought — and must cost nothing.
|
||||||
|
pauper := id.UserID("@pauper:x")
|
||||||
|
if err := p.mischiefBlessCmd(mischiefCtx(pauper), []string{string(target)}); err != nil {
|
||||||
|
t.Fatalf("bless: %v", err)
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(pauper); bal != 0 {
|
||||||
|
t.Errorf("a failed blessing moved the pauper's balance to %.0f", bal)
|
||||||
|
}
|
||||||
|
if got, _ := mischiefContractByID(c.ID); got.BlessingCount != 1 {
|
||||||
|
t.Errorf("a blessing nobody could pay for still landed (count %d)", got.BlessingCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalation moves the tier, the payout basis and the outlay together — which is
|
||||||
|
// what keeps the anti-collusion invariant (purse < what was spent) true after the
|
||||||
|
// room has piled on. It also happens exactly once.
|
||||||
|
func TestMischiefEscalation_RaisesBasisAndOutlayTogether(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p, _ := mischiefWindowPlugin(t)
|
||||||
|
target := id.UserID("@hunted:x")
|
||||||
|
piler := id.UserID("@piler:x")
|
||||||
|
rival := id.UserID("@rival:x")
|
||||||
|
p.euro.Credit(piler, 5000, "test")
|
||||||
|
p.euro.Credit(rival, 5000, "test")
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:x", target, "elite", true) // signed: paid > fee
|
||||||
|
elite, _ := mischiefTierByKey("elite")
|
||||||
|
boss, _ := mischiefTierByKey("boss")
|
||||||
|
delta := boss.Fee - elite.Fee
|
||||||
|
|
||||||
|
if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
|
||||||
|
t.Fatalf("escalate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Tier != "boss" || got.Fee != boss.Fee {
|
||||||
|
t.Fatalf("contract is %s/fee %d, want boss/%d", got.Tier, got.Fee, boss.Fee)
|
||||||
|
}
|
||||||
|
if got.Paid != c.Paid+delta {
|
||||||
|
t.Errorf("paid = %d, want %d (%d + the %d delta)", got.Paid, c.Paid+delta, c.Paid, delta)
|
||||||
|
}
|
||||||
|
if got.EscalatedBy != piler {
|
||||||
|
t.Errorf("escalated_by = %q, want %s — the unseal has nobody to name", got.EscalatedBy, piler)
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(piler); bal != float64(5000-delta) {
|
||||||
|
t.Errorf("escalator paid %.0f, want the %d delta", 5000-bal, delta)
|
||||||
|
}
|
||||||
|
// The invariant, after the escalation: the purse is still strictly less than
|
||||||
|
// the money that went in.
|
||||||
|
if purse := mischiefPurse(got.Fee, boss.PayoutPct); purse >= got.Paid {
|
||||||
|
t.Errorf("escalated purse %d >= outlay %d — collusion now beats !baltransfer", purse, got.Paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One step, once — whoever else was reaching for it is refunded.
|
||||||
|
if err := p.mischiefEscalateCmd(mischiefCtx(rival), []string{string(target)}); err != nil {
|
||||||
|
t.Fatalf("second escalate: %v", err)
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(rival); bal != 5000 {
|
||||||
|
t.Errorf("the losing escalator is out %.0f — a rejected escalation must cost nothing", 5000-bal)
|
||||||
|
}
|
||||||
|
after, _ := mischiefContractByID(c.ID)
|
||||||
|
if after.EscalationCount != 1 || after.Paid != got.Paid {
|
||||||
|
t.Errorf("contract escalated twice (count %d, paid %d)", after.EscalationCount, after.Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fizzle refunds `paid`, and after an escalation `paid` is TWO people's money.
|
||||||
|
// Refunding all of it to the buyer is a money pump: buy an elite for €350, have
|
||||||
|
// somebody bump it to a boss (+€850), let the target walk out of the dungeon, and
|
||||||
|
// the 90% refund hands the buyer €1080 — a €730 profit funded entirely by the
|
||||||
|
// escalator, who gets nothing and is never told.
|
||||||
|
func TestMischiefFizzle_RefundsTheEscalatorTheirOwnMoney(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p, _ := mischiefWindowPlugin(t)
|
||||||
|
target := id.UserID("@hunted:x")
|
||||||
|
buyer := id.UserID("@buyer:x")
|
||||||
|
piler := id.UserID("@piler:x")
|
||||||
|
p.euro.Credit(piler, 5000, "test")
|
||||||
|
exp := seedMischiefTarget(t, target, 5, 60)
|
||||||
|
|
||||||
|
c := seedContract(t, buyer, target, "elite", false)
|
||||||
|
elite, _ := mischiefTierByKey("elite")
|
||||||
|
boss, _ := mischiefTierByKey("boss")
|
||||||
|
delta := boss.Fee - elite.Fee
|
||||||
|
|
||||||
|
if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
|
||||||
|
t.Fatalf("escalate: %v", err)
|
||||||
|
}
|
||||||
|
// They extract before the boss arrives. Nobody's monster ever lands.
|
||||||
|
if _, _, err := forcedExtractExpedition(exp.ID, "went home"); err != nil {
|
||||||
|
t.Fatalf("forcedExtractExpedition: %v", err)
|
||||||
|
}
|
||||||
|
due := time.Now().UTC().AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(12 * time.Hour)
|
||||||
|
p.fireMischiefDeliveries(due)
|
||||||
|
|
||||||
|
if got, _ := mischiefContractByID(c.ID); got.Status != mischiefStatusFizzled {
|
||||||
|
t.Fatalf("contract status %s, want fizzled", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each stakeholder is refunded 90% of THEIR OWN stake — not 90% of the pooled
|
||||||
|
// `paid`, which is what the buyer would otherwise pocket.
|
||||||
|
wantBuyer := int(float64(c.Paid) * mischiefFizzleRefund)
|
||||||
|
wantPiler := int(float64(delta) * mischiefFizzleRefund)
|
||||||
|
if bal := int(p.euro.GetBalance(buyer)); bal != wantBuyer {
|
||||||
|
t.Errorf("buyer refunded %d, want %d — they were handed the escalator's stake", bal, wantBuyer)
|
||||||
|
}
|
||||||
|
if spent := 5000 - int(p.euro.GetBalance(piler)); spent != delta-wantPiler {
|
||||||
|
t.Errorf("escalator is out %d, want %d (their 10%% rake) — their refund went elsewhere",
|
||||||
|
spent, delta-wantPiler)
|
||||||
|
}
|
||||||
|
if pot, want := communityPotBalance(), (c.Paid-wantBuyer)+(delta-wantPiler); pot != want {
|
||||||
|
t.Errorf("pot raked %d, want %d", pot, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss tier is the "end their expedition" button, and the weekly cap on it is the
|
||||||
|
// target's protection. An escalation into boss is still a boss landing on them, so
|
||||||
|
// it answers to the same cap — otherwise the cap is bought around for the price of
|
||||||
|
// an elite plus the delta.
|
||||||
|
func TestMischiefEscalation_IntoBossRespectsTheWeeklyCap(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p, _ := mischiefWindowPlugin(t)
|
||||||
|
target := id.UserID("@bossed:x")
|
||||||
|
piler := id.UserID("@piler:x")
|
||||||
|
p.euro.Credit(piler, 5000, "test")
|
||||||
|
|
||||||
|
// They already took a boss this week (delivered, so it counts).
|
||||||
|
spent := seedContract(t, "@buyer:x", target, "boss", false)
|
||||||
|
resolveMischiefContract(spent.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer2:x", target, "elite", false)
|
||||||
|
if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
|
||||||
|
t.Fatalf("escalate: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Tier != "elite" {
|
||||||
|
t.Errorf("escalated to %s — the weekly boss cap was bought around", got.Tier)
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(piler); bal != 5000 {
|
||||||
|
t.Errorf("a refused escalation charged %.0f", 5000-bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss is the top of the ladder, and a target may not raise the price on their
|
||||||
|
// own head.
|
||||||
|
func TestMischiefEscalation_RefusesBossAndSelfService(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p, _ := mischiefWindowPlugin(t)
|
||||||
|
target := id.UserID("@top:x")
|
||||||
|
piler := id.UserID("@piler:x")
|
||||||
|
p.euro.Credit(piler, 5000, "test")
|
||||||
|
p.euro.Credit(target, 5000, "test")
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:x", target, "boss", false)
|
||||||
|
if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
|
||||||
|
t.Fatalf("escalate: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := mischiefContractByID(c.ID); got.EscalationCount != 0 {
|
||||||
|
t.Error("something got escalated past boss tier")
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(piler); bal != 5000 {
|
||||||
|
t.Errorf("charged %.0f for an impossible escalation", 5000-bal)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
c2 := seedContract(t, "@buyer:x", target, "grunt", false)
|
||||||
|
if err := p.mischiefEscalateCmd(mischiefCtx(target), []string{string(target)}); err != nil {
|
||||||
|
t.Fatalf("escalate: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := mischiefContractByID(c2.ID); got.EscalationCount != 0 {
|
||||||
|
t.Error("the target escalated their own contract")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window keeps writing to an open contract right up to the claim, so the
|
||||||
|
// delivery must build the fight from the row as it stands AFTER the claim — not
|
||||||
|
// from the copy the due-sweep handed it. A tier bought at the last second (an
|
||||||
|
// escalation) is the visible half of this: read stale, the buyer pays for a boss
|
||||||
|
// and the target fights a grunt.
|
||||||
|
func TestMischiefDelivery_ReadsTheContractAfterTheClaim(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p, _ := mischiefWindowPlugin(t)
|
||||||
|
uid := id.UserID("@lastsecond:x")
|
||||||
|
seedMischiefTarget(t, uid, 12, 400)
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:x", uid, "grunt", false)
|
||||||
|
// Somebody escalates in the gap between the due-sweep and the claim. `c` is now
|
||||||
|
// the stale copy the ticker is still holding.
|
||||||
|
elite, _ := mischiefTierByKey("elite")
|
||||||
|
if !escalateMischiefContract(c.ID, "grunt", elite, elite.Fee-c.Fee, "@piler:x") {
|
||||||
|
t.Fatal("escalation should have taken")
|
||||||
|
}
|
||||||
|
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
p.fireOneMischiefDelivery(c)
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Outcome != mischiefOutcomeSurvived {
|
||||||
|
t.Fatalf("target lost; this test needs a survival to read the purse (outcome %s)", got.Outcome)
|
||||||
|
}
|
||||||
|
want := mischiefPurse(elite.Fee, elite.PayoutPct)
|
||||||
|
if gained := int(p.euro.GetBalance(uid) - before); gained != want {
|
||||||
|
t.Errorf("survivor was paid %d, want the escalated %d purse — "+
|
||||||
|
"the delivery ran off the pre-claim copy of the contract", gained, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ward is a cushion for THIS fight and nothing else: it goes on the sheet as
|
||||||
|
// temp HP for the delivery and comes off after — without eating the well-rested
|
||||||
|
// cushion the target may already have been carrying out of the door.
|
||||||
|
func TestMischiefBlessing_CushionsTheFightThenClearsItself(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p, _ := mischiefWindowPlugin(t)
|
||||||
|
uid := id.UserID("@blessed:x")
|
||||||
|
exp := seedMischiefTarget(t, uid, 12, 400)
|
||||||
|
|
||||||
|
// They long-rested at home before setting out.
|
||||||
|
dc, _ := LoadDnDCharacter(uid)
|
||||||
|
dc.TempHP = 32
|
||||||
|
if err := SaveDnDCharacter(dc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:x", uid, "grunt", false)
|
||||||
|
for i := 0; i < mischiefBlessingCap; i++ {
|
||||||
|
if !blessMischiefContract(c.ID) {
|
||||||
|
t.Fatal("seed blessing rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.BlessingCount = mischiefBlessingCap
|
||||||
|
|
||||||
|
want := mischiefBlessingTempHP(400, mischiefBlessingCap) // 3 × 10% of 400
|
||||||
|
if want != 120 {
|
||||||
|
t.Fatalf("ward is %d HP, want 120 — the fight is being cushioned by something else", want)
|
||||||
|
}
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
if err := p.deliverMischief(c, exp); err != nil {
|
||||||
|
t.Fatalf("deliverMischief: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, _ := LoadDnDCharacter(uid)
|
||||||
|
if after.TempHP != 32 {
|
||||||
|
t.Errorf("temp HP is %d after the delivery, want the 32 they rested for — "+
|
||||||
|
"the ward must not linger, and must not eat their own cushion", after.TempHP)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,7 +190,17 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Room announcement
|
// Room announcement — but not for a player who isn't there.
|
||||||
|
//
|
||||||
|
// A bored adventurer (gogobee_boredom_plan.md) auto-harvests ore and junk
|
||||||
|
// into inventory on every run, and Robbie takes all of it, every day. Left
|
||||||
|
// alone, each abandoned character would file a public bulletin every single
|
||||||
|
// day, indefinitely, about somebody who stopped playing weeks ago. He still
|
||||||
|
// visits and still pays — that income is what keeps the neglected adventurer
|
||||||
|
// walking — he just doesn't announce a house with nobody in it.
|
||||||
|
if playerIsIdle(userID, time.Now().UTC()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
if gr != "" {
|
if gr != "" {
|
||||||
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)
|
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)
|
||||||
|
|||||||
@@ -405,8 +405,9 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
return fmt.Errorf("load chars: %w", err)
|
return fmt.Errorf("load chars: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
now := time.Now().UTC()
|
||||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
today := now.Format("2006-01-02")
|
||||||
|
yesterday := now.Add(-24 * time.Hour).Format("2006-01-02")
|
||||||
|
|
||||||
// Unified activity oracle — same source the daily report uses. Unions
|
// Unified activity oracle — same source the daily report uses. Unions
|
||||||
// adventure_activity_log + dnd_zone_run + dnd_expedition_log, so
|
// adventure_activity_log + dnd_zone_run + dnd_expedition_log, so
|
||||||
@@ -457,25 +458,32 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activity happened on the player's behalf without an explicit tap —
|
// Every hold below rests on one premise: the player engaged earlier to
|
||||||
// autopilot walked rooms, expedition log gained beats, a fight session
|
// kick this off, so the autopilot finishing the job shouldn't cost them
|
||||||
// is still locked open. Hold the streak: no bump, no shame, no decay.
|
// their streak. A boredom expedition has no such origin — nobody kicked
|
||||||
// The player engaged earlier to kick this off; autopilot is a feature,
|
// it off, and its player still hasn't come back. It earns them nothing
|
||||||
// not a way to game streaks, and absence of taps shouldn't strip
|
// and shields them from nothing (gogobee_boredom_plan.md §6).
|
||||||
// progress they earned through real play.
|
if !isBoredomDriven(char.UserID, now) {
|
||||||
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
|
// Activity happened on the player's behalf without an explicit tap —
|
||||||
continue
|
// autopilot walked rooms, expedition log gained beats, a fight session
|
||||||
}
|
// is still locked open. Hold the streak: no bump, no shame, no decay.
|
||||||
// Safety net for live state the activity logs don't reflect yet
|
// The player engaged earlier to kick this off; autopilot is a feature,
|
||||||
// (e.g. an active expedition that's been quiet today, or a combat
|
// not a way to game streaks, and absence of taps shouldn't strip
|
||||||
// session locked open across midnight without a log append).
|
// progress they earned through real play.
|
||||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
|
||||||
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
continue
|
||||||
} else if exp != nil {
|
}
|
||||||
continue
|
// Safety net for live state the activity logs don't reflect yet
|
||||||
}
|
// (e.g. an active expedition that's been quiet today, or a combat
|
||||||
if hasActiveCombatSession(char.UserID) {
|
// session locked open across midnight without a log append).
|
||||||
continue
|
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||||
|
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
||||||
|
} else if exp != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasActiveCombatSession(char.UserID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truly idle — shame DM + streak halve.
|
// Truly idle — shame DM + streak halve.
|
||||||
|
|||||||
@@ -695,7 +695,7 @@ func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBoss
|
|||||||
|
|
||||||
// No death / no hospital: floor the fighter at 1 HP so a loss never reads as
|
// No death / no hospital: floor the fighter at 1 HP so a loss never reads as
|
||||||
// a corpse. runZoneCombat already persisted the real HP cost.
|
// a corpse. runZoneCombat already persisted the real HP cost.
|
||||||
battered := worldBossFloorHP(userID)
|
battered := floorHPAtOne(userID)
|
||||||
|
|
||||||
dmg := result.EnemyEntryHP - result.EnemyEndHP
|
dmg := result.EnemyEntryHP - result.EnemyEndHP
|
||||||
if dmg < 0 {
|
if dmg < 0 {
|
||||||
@@ -718,10 +718,11 @@ func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBoss
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// worldBossFloorHP raises a player to 1 HP if the bout left them at zero, and
|
// floorHPAtOne raises a player to 1 HP if the fight left them at zero, and
|
||||||
// reports whether it had to. Keeps "no death" honest without undoing the real
|
// reports whether it had to. Keeps "no death" honest without undoing the real
|
||||||
// HP cost of a bout the player mostly survived.
|
// HP cost of a bout the player mostly survived. Shared by the two no-death
|
||||||
func worldBossFloorHP(userID id.UserID) bool {
|
// fights: the world-boss bout and a Mischief Makers delivery.
|
||||||
|
func floorHPAtOne(userID id.UserID) bool {
|
||||||
cur, _ := dndHPSnapshot(userID)
|
cur, _ := dndHPSnapshot(userID)
|
||||||
if cur > 0 {
|
if cur > 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -291,13 +291,13 @@ func TestWorldBossFloorHP(t *testing.T) {
|
|||||||
newWorldBossTestDB(t)
|
newWorldBossTestDB(t)
|
||||||
uid := id.UserID("@floor:test.invalid")
|
uid := id.UserID("@floor:test.invalid")
|
||||||
fightableChar(t, uid)
|
fightableChar(t, uid)
|
||||||
if worldBossFloorHP(uid) {
|
if floorHPAtOne(uid) {
|
||||||
t.Error("floor should be a no-op above 0 HP")
|
t.Error("floor should be a no-op above 0 HP")
|
||||||
}
|
}
|
||||||
if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
|
if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !worldBossFloorHP(uid) {
|
if !floorHPAtOne(uid) {
|
||||||
t.Error("floor should raise a 0-HP fighter")
|
t.Error("floor should raise a 0-HP fighter")
|
||||||
}
|
}
|
||||||
if cur, _ := dndHPSnapshot(uid); cur != 1 {
|
if cur, _ := dndHPSnapshot(uid); cur != 1 {
|
||||||
|
|||||||
47
internal/plugin/bootstrap_boredom_clock.go
Normal file
47
internal/plugin/bootstrap_boredom_clock.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootstrapBoredomClock seeds player_meta.last_player_action_at for rows that
|
||||||
|
// predate the column (gogobee_boredom_plan.md §1).
|
||||||
|
//
|
||||||
|
// Without this, the column is NULL for every existing player on the deploy that
|
||||||
|
// adds it, and loadBoredomCandidates falls back to created_at — which is when
|
||||||
|
// the character was *made*, not when it was last played. Every alive character
|
||||||
|
// on the server is therefore months past the idle threshold on the first tick
|
||||||
|
// after the deploy, and the whole player base gets marched into a dungeon at
|
||||||
|
// once, coins debited, thirty minutes after restart. A player who typed a
|
||||||
|
// command a minute before the deploy is no exception: the clock has never heard
|
||||||
|
// of them.
|
||||||
|
//
|
||||||
|
// last_active_at is the best proxy we have for "the character did something
|
||||||
|
// recently" and is exactly right at deploy time: recent for anyone still
|
||||||
|
// playing, stale for anyone who isn't. It is unusable as the clock itself (the
|
||||||
|
// autopilot bumps it through saveAdvCharacter, so a bored character would keep
|
||||||
|
// refreshing its own idle clock — see markPlayerAction), which is why this is a
|
||||||
|
// one-time seed and not a fallback in the query.
|
||||||
|
//
|
||||||
|
// Re-running on every boot is safe and intentionally a no-op:
|
||||||
|
// - a row seeded here, or stamped by a real action, is no longer NULL;
|
||||||
|
// - a character the boredom ticker has already sent out (last_boredom_at set)
|
||||||
|
// is skipped, so a restart mid-boredom can't hand it a fresh idle clock off
|
||||||
|
// the autopilot's own writes.
|
||||||
|
func bootstrapBoredomClock() {
|
||||||
|
res, err := db.Get().Exec(`
|
||||||
|
UPDATE player_meta
|
||||||
|
SET last_player_action_at = COALESCE(last_active_at, created_at)
|
||||||
|
WHERE last_player_action_at IS NULL
|
||||||
|
AND last_boredom_at IS NULL
|
||||||
|
AND COALESCE(last_active_at, created_at) IS NOT NULL`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("bootstrap: boredom clock seed failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
slog.Warn("bootstrap: seeded boredom idle clock from last_active_at", "rows", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
237
internal/plugin/bootstrap_pete_news.go
Normal file
237
internal/plugin/bootstrap_pete_news.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
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.
|
||||||
|
//
|
||||||
|
// Every fact here is a BULLETIN, like every fact gogobee files (1cbd68a). Priority
|
||||||
|
// is the one tier that makes Pete post a live Matrix beat, and TwinBee already
|
||||||
|
// narrates these moments in the games room as they happen — a priority fact would
|
||||||
|
// have Pete read his version back to the people who watched it. Nothing in the
|
||||||
|
// back catalogue is news, least of all a death from three weeks ago.
|
||||||
|
//
|
||||||
|
// NoPush independently short-circuits Pete's beat today, so this is belt AND
|
||||||
|
// braces: the tier is the rule, and it must not depend on NoPush staying in front
|
||||||
|
// of it. Rendering keys off event_type, not tier, so nothing on the site moves.
|
||||||
|
//
|
||||||
|
// 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: "bulletin",
|
||||||
|
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: "bulletin",
|
||||||
|
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
|
// this is a no-op there); mirror it here so the entry banner and the opening
|
||||||
// round resolve against the same ceiling startPartyCombatSession persisted and
|
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||||||
// the rebuilt rounds use.
|
// the rebuilt rounds use.
|
||||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
|
||||||
|
|
||||||
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
||||||
// per seat onto the session and its participant rows, so they survive the
|
// per seat onto the session and its participant rows, so they survive the
|
||||||
@@ -425,7 +425,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
|||||||
// returns the spell plus the resolved slot level. errMsg is non-empty and
|
// returns the spell plus the resolved slot level. errMsg is non-empty and
|
||||||
// player-facing on any validation failure. It performs NO resource spend —
|
// player-facing on any validation failure. It performs NO resource spend —
|
||||||
// the caller debits the slot only once the round is about to resolve.
|
// the caller debits the slot only once the round is about to resolve.
|
||||||
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
//
|
||||||
|
// It takes the seat, not just the user, because "do you know this spell" is a
|
||||||
|
// question about a combatant and only *usually* a question about a database row:
|
||||||
|
// the hired companion has no rows and answers it from his synthetic sheet.
|
||||||
|
func parseCombatCast(sess *CombatSession, seat int, userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
||||||
tokens := strings.Fields(args)
|
tokens := strings.Fields(args)
|
||||||
upcast := 0
|
upcast := 0
|
||||||
var spellTokens []string
|
var spellTokens []string
|
||||||
@@ -473,7 +477,7 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
|
|||||||
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
|
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
known, prepared, err := playerKnowsSpell(userID, spell.ID)
|
known, prepared, err := seatKnowsSpell(sess, seat, userID, spell.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SpellDefinition{}, 0, "Couldn't check your spell list."
|
return SpellDefinition{}, 0, "Couldn't check your spell list."
|
||||||
}
|
}
|
||||||
@@ -526,12 +530,92 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
|||||||
// the roster so the buff is live for the round's enemy turn. Before P5 that
|
// the roster so the buff is live for the round's enemy turn. Before P5 that
|
||||||
// delta went to the session's embedded copy — seat 0 — so a party member
|
// delta went to the session's embedded copy — seat 0 — so a party member
|
||||||
// buffing themselves would have buffed the leader.
|
// buffing themselves would have buffed the leader.
|
||||||
|
// splitCastTarget peels an optional ally target off the end of a `!cast` argument
|
||||||
|
// — `cure wounds @alex`, or just `cure wounds alex` — and resolves it to a seat.
|
||||||
|
//
|
||||||
|
// It resolves against the people *in this fight* rather than the room, which is
|
||||||
|
// both cheaper (no ResolveUser round-trip, no RoomID to thread down here) and
|
||||||
|
// more correct: the only legal target of a combat heal is somebody sitting in the
|
||||||
|
// combat. Anything it does not recognise is left on the string for the spell
|
||||||
|
// parser, so `!cast cure wounds` and `!cast fireball 3` behave exactly as before.
|
||||||
|
//
|
||||||
|
// Both spellings work: the `--target @alex` flag that `!help` has advertised all
|
||||||
|
// along (and that parseCombatCast has been silently swallowing since SP2 —
|
||||||
|
// "reserved for SP3, accept and ignore"), and a plain trailing `@alex`.
|
||||||
|
//
|
||||||
|
// Returns (remainingArgs, seat, errMsg). seat is -1 when no target was named.
|
||||||
|
func splitCastTarget(ct *combatTurn, caster int, args string) (string, int, string) {
|
||||||
|
args = strings.TrimSpace(args)
|
||||||
|
if args == "" || !ct.isParty() {
|
||||||
|
return args, -1, ""
|
||||||
|
}
|
||||||
|
fields := strings.Fields(args)
|
||||||
|
|
||||||
|
// `--target <who>` anywhere in the string.
|
||||||
|
explicit, name := false, ""
|
||||||
|
for i := 0; i < len(fields); i++ {
|
||||||
|
if !strings.EqualFold(fields[i], "--target") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(fields) {
|
||||||
|
return args, -1, "`--target` needs a name: `!cast cure wounds --target @alex`."
|
||||||
|
}
|
||||||
|
explicit, name = true, strings.TrimPrefix(fields[i+1], "@")
|
||||||
|
fields = append(fields[:i], fields[i+2:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
last := fields[len(fields)-1]
|
||||||
|
// A bare number is a slot level (`!cast fireball 3`), never a target.
|
||||||
|
if _, err := strconv.Atoi(last); err == nil {
|
||||||
|
return args, -1, ""
|
||||||
|
}
|
||||||
|
explicit = strings.HasPrefix(last, "@")
|
||||||
|
name = strings.TrimPrefix(last, "@")
|
||||||
|
if name == "" {
|
||||||
|
return args, -1, ""
|
||||||
|
}
|
||||||
|
fields = fields[:len(fields)-1]
|
||||||
|
}
|
||||||
|
// From here `fields` is the spell tokens with the target removed.
|
||||||
|
rest := strings.Join(fields, " ")
|
||||||
|
|
||||||
|
for i, c := range ct.players {
|
||||||
|
uid := ct.sess.seatUserID(i)
|
||||||
|
if !strings.EqualFold(c.Name, name) &&
|
||||||
|
!strings.EqualFold(uid, name) &&
|
||||||
|
!strings.EqualFold(id.UserID(uid).Localpart(), name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == caster {
|
||||||
|
// Targeting yourself is just casting it on yourself, which is what the
|
||||||
|
// engine does by default. Drop the target and carry on.
|
||||||
|
return rest, -1, ""
|
||||||
|
}
|
||||||
|
return rest, i, ""
|
||||||
|
}
|
||||||
|
// An explicit @mention that matches nobody in the fight is a mistake worth
|
||||||
|
// naming — silently casting it on yourself would waste the slot.
|
||||||
|
if explicit {
|
||||||
|
return args, -1, fmt.Sprintf("**%s** isn't in this fight. Cast it on someone who is.", name)
|
||||||
|
}
|
||||||
|
return args, -1, ""
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||||||
noop := func(bool) {}
|
noop := func(bool) {}
|
||||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||||
|
|
||||||
advChar, _ := loadAdvCharacter(uid)
|
// §1 — a heal may name somebody else in the fight: `!cast cure wounds @alex`.
|
||||||
c, err := p.ensureCharForDnDCmd(uid, advChar)
|
// Split the target off before the spell is parsed, so the spell parser sees
|
||||||
|
// the same string it always has.
|
||||||
|
args, targetSeat, targetErr := splitCastTarget(ct, seat, args)
|
||||||
|
if targetErr != "" {
|
||||||
|
return PlayerAction{}, noop, targetErr
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := p.seatCastSheet(ct.sess, uid)
|
||||||
if err != nil || c == nil {
|
if err != nil || c == nil {
|
||||||
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
|
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
|
||||||
}
|
}
|
||||||
@@ -540,7 +624,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
|
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
|
||||||
}
|
}
|
||||||
|
|
||||||
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
|
spell, slotLevel, errMsg := parseCombatCast(ct.sess, seat, uid, c, strings.TrimSpace(args))
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
return PlayerAction{}, noop, errMsg
|
return PlayerAction{}, noop, errMsg
|
||||||
}
|
}
|
||||||
@@ -551,7 +635,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
|
|
||||||
refund := func(ok bool) {
|
refund := func(ok bool) {
|
||||||
if !ok && spell.Level > 0 {
|
if !ok && spell.Level > 0 {
|
||||||
_ = refundSpellSlot(uid, slotLevel)
|
_ = refundSeatSlot(ct.sess, seat, uid, slotLevel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +652,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
return PlayerAction{}, noop, fmt.Sprintf(
|
return PlayerAction{}, noop, fmt.Sprintf(
|
||||||
"%s has no effect the turn-based engine can apply yet.", spell.Name)
|
"%s has no effect the turn-based engine can apply yet.", spell.Name)
|
||||||
}
|
}
|
||||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
|
||||||
return PlayerAction{}, noop, msg
|
return PlayerAction{}, noop, msg
|
||||||
}
|
}
|
||||||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||||||
@@ -590,7 +674,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
return PlayerAction{}, noop, fmt.Sprintf(
|
return PlayerAction{}, noop, fmt.Sprintf(
|
||||||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
|
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
|
||||||
}
|
}
|
||||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
|
||||||
return PlayerAction{}, noop, msg
|
return PlayerAction{}, noop, msg
|
||||||
}
|
}
|
||||||
// Park the Necromancy kill-heal stash on the casting seat. The
|
// Park the Necromancy kill-heal stash on the casting seat. The
|
||||||
@@ -612,6 +696,20 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
PlayerHeal: out.PlayerHeal,
|
PlayerHeal: out.PlayerHeal,
|
||||||
EnemySkip: out.EnemySkip,
|
EnemySkip: out.EnemySkip,
|
||||||
}
|
}
|
||||||
|
// §1 — redirect the heal onto the named ally. The roll is the same; only
|
||||||
|
// the body it lands on changes. This is the line that makes a cleric a
|
||||||
|
// cleric: until it existed, every heal in the engine was a self-heal, and
|
||||||
|
// the class whose whole job is keeping other people upright could not put
|
||||||
|
// a single hit point on a friend.
|
||||||
|
if targetSeat >= 0 && targetSeat != seat {
|
||||||
|
if out.PlayerHeal <= 0 {
|
||||||
|
return PlayerAction{}, refund, fmt.Sprintf(
|
||||||
|
"%s isn't something you can cast on someone else. Drop the target to cast it yourself.", spell.Name)
|
||||||
|
}
|
||||||
|
eff.AllyHeal, eff.AllySeat = out.PlayerHeal, targetSeat
|
||||||
|
eff.PlayerHeal = 0
|
||||||
|
eff.Label = fmt.Sprintf("%s → %s (+%d HP)", spell.Name, ct.players[targetSeat].Name, out.PlayerHeal)
|
||||||
|
}
|
||||||
// Concentration AOE damage spells linger: the burst lands this round
|
// Concentration AOE damage spells linger: the burst lands this round
|
||||||
// (EnemyDamage) and the same value re-ticks every round_end after, via
|
// (EnemyDamage) and the same value re-ticks every round_end after, via
|
||||||
// the engine's concentration aura. spiritual_weapon already covers the
|
// the engine's concentration aura. spiritual_weapon already covers the
|
||||||
@@ -640,18 +738,30 @@ func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
|
|||||||
// success the caller owns the slot and must refundSpellSlot if the round itself
|
// success the caller owns the slot and must refundSpellSlot if the round itself
|
||||||
// errors. Material components (rare in a fight) are not refunded if the slot
|
// errors. Material components (rare in a fight) are not refunded if the slot
|
||||||
// debit then fails — matching the auto-resolve cast path.
|
// debit then fails — matching the auto-resolve cast path.
|
||||||
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
func (p *AdventurePlugin) chargeSpellCost(sess *CombatSession, seat int, userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||||||
|
_, _, companion := seatCompanionLoadout(sess, userID)
|
||||||
|
|
||||||
|
// The companion carries no purse — he has no wallet to debit and no inventory
|
||||||
|
// to stock one from, so a component cost is not a price he can pay but a spell
|
||||||
|
// he cannot cast. Refusing here (rather than letting the debit fail on an empty
|
||||||
|
// account) keeps that an explicit rule instead of an accident of his balance.
|
||||||
if spell.MaterialCost > 0 {
|
if spell.MaterialCost > 0 {
|
||||||
|
if companion {
|
||||||
|
return fmt.Sprintf("%s needs a component %s doesn't carry.", spell.Name, companionDisplayName)
|
||||||
|
}
|
||||||
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
|
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||||
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if spell.Level > 0 {
|
if spell.Level > 0 {
|
||||||
ok, serr := consumeSpellSlot(userID, slotLevel)
|
ok, serr := consumeSeatSlot(sess, seat, userID, slotLevel)
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
return "Couldn't consume slot: " + serr.Error()
|
return "Couldn't consume slot: " + serr.Error()
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if companion {
|
||||||
|
return fmt.Sprintf("%s is out of level-%d energy.", companionDisplayName, slotLevel)
|
||||||
|
}
|
||||||
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
|
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,22 @@ type Combatant struct {
|
|||||||
Mods CombatModifiers
|
Mods CombatModifiers
|
||||||
IsPlayer bool
|
IsPlayer bool
|
||||||
Ability *MonsterAbility // non-nil for monsters with a special ability
|
Ability *MonsterAbility // non-nil for monsters with a special ability
|
||||||
|
|
||||||
|
// SeatWeight is what this seat costs the enemy: 1.0 is a full peer of the
|
||||||
|
// leader, and less than that is a seat that brings less to the fight. Zero
|
||||||
|
// means "unset" and reads as 1.0, so every existing call site — and every solo
|
||||||
|
// fight — is unchanged.
|
||||||
|
//
|
||||||
|
// The enemy's HP bump and its action economy scale on the SUM of these rather
|
||||||
|
// than on a seat count. A seat count charges the boss the same for an
|
||||||
|
// under-levelled friend, a hired NPC, and a peer, which is why hiring a
|
||||||
|
// below-median body was measurably worse than going alone: he cost a full
|
||||||
|
// seat's worth of boss and did not give a full seat's worth back.
|
||||||
|
//
|
||||||
|
// It is derived from the seat's identity (level, and whether it is a hireling),
|
||||||
|
// NOT from fight state — so every per-round rebuild recomputes the same number
|
||||||
|
// and there is nothing to persist. See seatWeight.
|
||||||
|
SeatWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type CombatPhase struct {
|
type CombatPhase struct {
|
||||||
@@ -220,7 +236,14 @@ type CombatEvent struct {
|
|||||||
//
|
//
|
||||||
// It exists so a party's play-by-play can name the right person. Solo events
|
// It exists so a party's play-by-play can name the right person. Solo events
|
||||||
// are all seat 0, and the omitempty tag keeps the field out of every solo
|
// are all seat 0, and the omitempty tag keeps the field out of every solo
|
||||||
// turn_log_json — rows written before N3/P5 decode unchanged.
|
// turn_log_json — rows written before N3/P5 decode unchanged, and a fight in
|
||||||
|
// flight across a deploy resumes byte-identically (TestP5Fields_StayOffSoloRows).
|
||||||
|
//
|
||||||
|
// The omitempty makes seat 0 and "no seat" identical on the wire, which is
|
||||||
|
// fine for persistence and actively misleading in a diagnostic trace — it hid
|
||||||
|
// a companion who never swung, making the fight look like it had one seat.
|
||||||
|
// Do NOT fix that here; the wire format is load-bearing. The sim's trace
|
||||||
|
// serializes through simTraceEvent, which always emits the seat.
|
||||||
Seat int `json:"Seat,omitempty"`
|
Seat int `json:"Seat,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatP
|
|||||||
// scaled action economy to actually threaten each member. Solo is unchanged
|
// scaled action economy to actually threaten each member. Solo is unchanged
|
||||||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||||||
if len(players) > 1 {
|
if len(players) > 1 {
|
||||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
|
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players))
|
||||||
}
|
}
|
||||||
enemyStart := enemy.Stats.MaxHP
|
enemyStart := enemy.Stats.MaxHP
|
||||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||||
@@ -307,11 +307,26 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
|
|||||||
// between — a per-round coin-flip for the extra action is the only way to land
|
// between — a per-round coin-flip for the extra action is the only way to land
|
||||||
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
||||||
// stream is untouched.
|
// stream is untouched.
|
||||||
|
// §2(b): the budget counts the seats still STANDING, re-derived every round —
|
||||||
|
// not the seats that walked in.
|
||||||
|
//
|
||||||
|
// It used to read len(st.actors), which includes the dead. So a party that lost a
|
||||||
|
// member kept paying for them: the survivor faced a boss still swinging at
|
||||||
|
// two-player cadence, alone. That is a death spiral with the arrow pointing the
|
||||||
|
// wrong way — the moment a party is losing, the engine hits it harder — and it is
|
||||||
|
// the single nastiest thing the companion sweep turned up, because it is not a
|
||||||
|
// companion bug at all. It has been live for every human party since N3.
|
||||||
|
//
|
||||||
|
// A corpse does not threaten anybody, and the enemy has no reason to keep spending
|
||||||
|
// actions on one.
|
||||||
func enemyActionsThisRound(st *combatState) int {
|
func enemyActionsThisRound(st *combatState) int {
|
||||||
n := len(st.actors)
|
if livingActors(st) < 2 {
|
||||||
if n < 2 {
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
// The summed weight of the seats still standing — not a head count of them.
|
||||||
|
// A seat that brings half a peer buys the boss half a peer's worth of extra
|
||||||
|
// swings, and a seat that is down buys none at all.
|
||||||
|
n := livingWeight(st)
|
||||||
exp := partyActionExpectation(n)
|
exp := partyActionExpectation(n)
|
||||||
base := int(exp)
|
base := int(exp)
|
||||||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||||||
@@ -320,6 +335,29 @@ func enemyActionsThisRound(st *combatState) int {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// livingActors counts the seats still standing.
|
||||||
|
func livingActors(st *combatState) int {
|
||||||
|
n := 0
|
||||||
|
for _, a := range st.actors {
|
||||||
|
if a.playerHP > 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// livingWeight is livingActors in the currency the enemy actually charges in: the
|
||||||
|
// summed SeatWeight of the seats still standing.
|
||||||
|
func livingWeight(st *combatState) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for _, a := range st.actors {
|
||||||
|
if a.playerHP > 0 {
|
||||||
|
total += combatantWeight(a.c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
// partyActionExpectation is the expected number of enemy attack-actions per round
|
// partyActionExpectation is the expected number of enemy attack-actions per round
|
||||||
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
||||||
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
||||||
@@ -333,17 +371,108 @@ func enemyActionsThisRound(st *combatState) int {
|
|||||||
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
||||||
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
||||||
// composition below its solo clear rate — bringing a friend is never a penalty.
|
// composition below its solo clear rate — bringing a friend is never a penalty.
|
||||||
func partyActionExpectation(n int) float64 {
|
// It takes a fractional party size — the summed SeatWeight of the living seats,
|
||||||
|
// not a head count — and interpolates linearly between the integer knots the P8
|
||||||
|
// sweep actually tuned: (1, 1.0), (2, 2.4), and 2n−1 from 3 up. Every integer
|
||||||
|
// input therefore returns exactly what it always returned, so a solo fight and a
|
||||||
|
// party of peers are byte-identical and the balance corpus is untouched. Only a
|
||||||
|
// roster of *unequal* seats lands between the knots, which is the entire point:
|
||||||
|
// a half-strength body should buy the boss half a body's worth of extra swings.
|
||||||
|
func partyActionExpectation(n float64) float64 {
|
||||||
switch {
|
switch {
|
||||||
case n < 2:
|
case n <= 1:
|
||||||
return 1
|
return 1
|
||||||
case n == 2:
|
case n <= 2:
|
||||||
return 2.4
|
// (1, 1.0) → (2, 2.4)
|
||||||
|
return 1 + 1.4*(n-1)
|
||||||
|
case n <= 3:
|
||||||
|
// (2, 2.4) → (3, 5.0); the original curve stepped here, so the segment is
|
||||||
|
// steeper than the 2n−1 line it joins.
|
||||||
|
return 2.4 + 2.6*(n-2)
|
||||||
default:
|
default:
|
||||||
return float64(2*n - 1)
|
return 2*n - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seatWeight is what one seat costs the enemy, relative to the leader.
|
||||||
|
//
|
||||||
|
// **Level, not stats.** A power score built from HP × damage would rank a cleric
|
||||||
|
// below a fighter and quietly make every mixed *human* party easier — the support
|
||||||
|
// classes would stop paying their way. Level is the class-neutral measure of what
|
||||||
|
// a body is worth, and it is exactly the axis on which the two seats that violated
|
||||||
|
// the peer assumption differ: an under-levelled friend, and a hireling who arrives
|
||||||
|
// a level down by design.
|
||||||
|
//
|
||||||
|
// A peer of the leader weighs 1.0, so a party of equals is unchanged. Nobody weighs
|
||||||
|
// more than a peer: out-levelling the leader does not make the boss harder for
|
||||||
|
// everyone else.
|
||||||
|
func seatWeight(seatLevel, leaderLevel int, companion bool) float64 {
|
||||||
|
if leaderLevel <= 0 || seatLevel <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
w := float64(seatLevel) / float64(leaderLevel)
|
||||||
|
if w > 1 {
|
||||||
|
w = 1
|
||||||
|
}
|
||||||
|
if companion {
|
||||||
|
// The layers a player accumulates and a hireling never will: no subclass, no
|
||||||
|
// magic items, no armed ability, and gear that is never Masterwork. Levels
|
||||||
|
// cannot see any of that, so it is priced here.
|
||||||
|
w *= companionSeatWeight
|
||||||
|
}
|
||||||
|
if w < seatWeightFloor {
|
||||||
|
w = seatWeightFloor
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// companionSeatWeight is the hireling discount — the one tunable in this model.
|
||||||
|
companionSeatWeight = 0.65
|
||||||
|
// seatWeightFloor stops a badly under-levelled seat from becoming free. A body
|
||||||
|
// in the fight is always worth something to the enemy: it is one more thing that
|
||||||
|
// has to be killed.
|
||||||
|
seatWeightFloor = 0.35
|
||||||
|
)
|
||||||
|
|
||||||
|
// partyWeight sums the seats' weights. An unset weight (0) reads as a full peer,
|
||||||
|
// which is what every combatant built before this existed is.
|
||||||
|
func partyWeight(players []*Combatant) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for _, c := range players {
|
||||||
|
total += combatantWeight(c)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func combatantWeight(c *Combatant) float64 {
|
||||||
|
if c == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if c.SeatWeight <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return c.SeatWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// partyWeightOf is partyWeight for a value slice.
|
||||||
|
func partyWeightOf(players []Combatant) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for i := range players {
|
||||||
|
total += combatantWeight(&players[i])
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatSetupWeight is partyWeight for a roster that is still being seated.
|
||||||
|
func seatSetupWeight(seats []CombatSeatSetup) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for _, s := range seats {
|
||||||
|
total += combatantWeight(s.C)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
||||||
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
||||||
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
||||||
@@ -355,18 +484,27 @@ func partyActionExpectation(n int) float64 {
|
|||||||
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
||||||
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||||||
// not the 100% faceroll a single enemy swing produced.
|
// not the 100% faceroll a single enemy swing produced.
|
||||||
func partyEnemyHPScale(rosterSize int) float64 {
|
// Like partyActionExpectation it takes the summed SeatWeight rather than a head
|
||||||
if rosterSize < 2 {
|
// count, and ramps between the same knots: weight 1 (solo) pays nothing, weight 2
|
||||||
|
// (a peer at the leader's side) pays the full 1.15 the P8 sweep tuned. Integer
|
||||||
|
// inputs are byte-exact, so solo and a party of peers are unchanged; a roster
|
||||||
|
// carrying a below-median body pays proportionally less, because it brought
|
||||||
|
// proportionally less.
|
||||||
|
func partyEnemyHPScale(weight float64) float64 {
|
||||||
|
if weight <= 1 {
|
||||||
return 1.0
|
return 1.0
|
||||||
}
|
}
|
||||||
return 1.15
|
if weight >= 2 {
|
||||||
|
return 1.15
|
||||||
|
}
|
||||||
|
return 1.0 + 0.15*(weight-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
||||||
// rule, so every call site (the auto-resolve engine, the party session's initial
|
// rule, so every call site (the auto-resolve engine, the party session's initial
|
||||||
// persist, and the per-turn rebuild) agrees on the same number.
|
// persist, and the per-turn rebuild) agrees on the same number.
|
||||||
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
|
func scaledEnemyMaxHP(baseMaxHP int, weight float64) int {
|
||||||
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
|
return int(float64(baseMaxHP) * partyEnemyHPScale(weight))
|
||||||
}
|
}
|
||||||
|
|
||||||
// enemyActionPlan is the shared action budget both combat engines resolve an
|
// enemyActionPlan is the shared action budget both combat engines resolve an
|
||||||
|
|||||||
@@ -142,11 +142,22 @@ func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
|
|||||||
if got := partyActionExpectation(2); got != 2.4 {
|
if got := partyActionExpectation(2); got != 2.4 {
|
||||||
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
||||||
}
|
}
|
||||||
|
// The curve now takes a fractional weight rather than a head count, so that a
|
||||||
|
// below-median seat costs the enemy less than a peer does. Every INTEGER input
|
||||||
|
// must still return exactly what it always returned — that is what keeps solo
|
||||||
|
// and a party of peers byte-identical, and the balance corpus with them.
|
||||||
for n := 3; n <= 5; n++ {
|
for n := 3; n <= 5; n++ {
|
||||||
if got, want := partyActionExpectation(n), float64(2*n-1); got != want {
|
if got, want := partyActionExpectation(float64(n)), float64(2*n-1); got != want {
|
||||||
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
|
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A duo carrying a half-strength body sits between soloing and a true duo.
|
||||||
|
if got := partyActionExpectation(1.5); got <= 1 || got >= 2.4 {
|
||||||
|
t.Fatalf("weight 1.5 action expectation = %v, want strictly between 1 and 2.4", got)
|
||||||
|
}
|
||||||
|
if got := partyEnemyHPScale(1.5); got <= 1.0 || got >= 1.15 {
|
||||||
|
t.Fatalf("weight 1.5 HP scale = %v, want strictly between 1.0 and 1.15", got)
|
||||||
|
}
|
||||||
if got := partyEnemyHPScale(3); got != 1.15 {
|
if got := partyEnemyHPScale(3); got != 1.15 {
|
||||||
t.Fatalf("party HP scale = %v, want 1.15", got)
|
t.Fatalf("party HP scale = %v, want 1.15", got)
|
||||||
}
|
}
|
||||||
|
|||||||
159
internal/plugin/combat_party_characterization_test.go
Normal file
159
internal/plugin/combat_party_characterization_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Party characterization — the net that did not exist.
|
||||||
|
//
|
||||||
|
// TestCombatCharacterization pins SOLO combat exhaustively, and it has been the
|
||||||
|
// tripwire for every balance change since. Party combat had nothing. The whole
|
||||||
|
// N-body layer — initiative order, per-seat resolution, enemy targeting, the P8
|
||||||
|
// action-economy and HP scaling, downed-seat handling — shipped unpinned.
|
||||||
|
//
|
||||||
|
// What that cost: N3 shipped a Cleric class that cannot heal a single ally (no
|
||||||
|
// action in the engine can target another seat) and not one test went red. The
|
||||||
|
// hired companion then stood in fights doing nothing, and the unit tests stayed
|
||||||
|
// green through that too. It took a 1500-run expedition sweep to see either.
|
||||||
|
//
|
||||||
|
// So: pin it. Any change to the party engine now has to state, in the diff, what
|
||||||
|
// it moved. Regenerate ONLY on purpose:
|
||||||
|
//
|
||||||
|
// go test ./internal/plugin -run TestPartyCharacterization -update
|
||||||
|
//
|
||||||
|
// This golden covers simulateParty — the auto-resolve engine, which decides most
|
||||||
|
// expedition rooms and which the balance harness is built on. The turn engine's
|
||||||
|
// party half (manual play + boss fights) needs DB fixtures and is pinned by
|
||||||
|
// combat_turn_party_test.go; widening THAT into a golden is the follow-up.
|
||||||
|
|
||||||
|
var updatePartyGolden = flag.Bool("update-party", false, "rewrite the party characterization golden")
|
||||||
|
|
||||||
|
// partyScenario is one pinned N-body fight.
|
||||||
|
type partyScenario struct {
|
||||||
|
name string
|
||||||
|
seats []Combatant
|
||||||
|
enemy Combatant
|
||||||
|
phases []CombatPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatOfClass shapes a seat that stands in for a class archetype. These are
|
||||||
|
// deliberately engine-level (stat blocks, not sheets): the golden pins what the
|
||||||
|
// ENGINE does with a roster, not what the character layer feeds it.
|
||||||
|
func seatOfClass(name string, hp, atk, def, speed int) Combatant {
|
||||||
|
c := basePlayer()
|
||||||
|
c.Name = name
|
||||||
|
c.Stats.MaxHP = hp
|
||||||
|
c.Stats.Attack = atk
|
||||||
|
c.Stats.Defense = def
|
||||||
|
c.Stats.Speed = speed
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func partyCharacterizationScenarios() []partyScenario {
|
||||||
|
tank := seatOfClass("Tank", 160, 14, 12, 8)
|
||||||
|
striker := seatOfClass("Striker", 90, 22, 6, 14)
|
||||||
|
support := seatOfClass("Support", 110, 11, 9, 10)
|
||||||
|
// The seat this whole plan is about: a body that is real but below median.
|
||||||
|
// If scaling ever stops overcharging for it, THIS line is what moves.
|
||||||
|
weak := seatOfClass("Weak", 70, 8, 5, 9)
|
||||||
|
// A seat that will fall early. Its corpse must not keep buffing the enemy —
|
||||||
|
// when §2 lands, this scenario is the one that proves it.
|
||||||
|
glass := seatOfClass("Glass", 12, 18, 0, 16)
|
||||||
|
|
||||||
|
return []partyScenario{
|
||||||
|
{"duo/even", []Combatant{tank, striker}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"duo/tank+support", []Combatant{tank, support}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"duo/median+weak", []Combatant{tank, weak}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"duo/glass falls early", []Combatant{tank, glass}, hardHitEnemy(), dungeonCombatPhases},
|
||||||
|
{"trio/even", []Combatant{tank, striker, support}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"trio/one weak seat", []Combatant{tank, striker, weak}, tankyEnemy(), dungeonCombatPhases},
|
||||||
|
{"trio/two glass seats", []Combatant{tank, glass, glass}, hardHitEnemy(), dungeonCombatPhases},
|
||||||
|
{"duo/vs ability enemy", []Combatant{tank, striker},
|
||||||
|
abilityEnemy("Wither", "poison", "Duel"), dungeonCombatPhases},
|
||||||
|
// The degenerate case. A one-seat roster MUST stay bit-identical to solo —
|
||||||
|
// it is the invariant the entire balance corpus rests on, and the reason
|
||||||
|
// the solo golden is allowed to stay untouched while this file grows.
|
||||||
|
{"solo/one-seat roster", []Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPartyResult prints the seat on EVERY line. The solo formatter does not
|
||||||
|
// (it cannot; there is only one seat), and CombatEvent.Seat is `omitempty`, so a
|
||||||
|
// JSON trace renders seat 0 and "no seat" identically — which sent me chasing a
|
||||||
|
// phantom "the companion never swings" bug for an hour. A party golden that
|
||||||
|
// could not tell you WHO acted would be worth very little.
|
||||||
|
func formatPartyResult(r PartyCombatResult) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "result: won=%v timedOut=%v rounds=%d survivors=%v\n",
|
||||||
|
r.PlayerWon, r.TimedOut, r.TotalRounds, r.AnySurvivor())
|
||||||
|
fmt.Fprintf(&b, " enemy: start=%d entry=%d end=%d\n", r.EnemyStartHP, r.EnemyEntryHP, r.EnemyEndHP)
|
||||||
|
for i, s := range r.Seats {
|
||||||
|
fmt.Fprintf(&b, " seat[%d]: start=%d entry=%d end=%d\n",
|
||||||
|
i, s.PlayerStartHP, s.PlayerEntryHP, s.PlayerEndHP)
|
||||||
|
}
|
||||||
|
for i, e := range r.Events {
|
||||||
|
fmt.Fprintf(&b, " [%02d] r%d seat=%d %-12s %-8s %-16s dmg=%-5d php=%-4d ehp=%-4d roll=%d/%d desc=%q\n",
|
||||||
|
i, e.Round, e.Seat, e.Phase, e.Actor, e.Action, e.Damage, e.PlayerHP, e.EnemyHP,
|
||||||
|
e.Roll, e.RollAgainst, e.Desc)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartyCharacterization(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, sc := range partyCharacterizationScenarios() {
|
||||||
|
for _, seed := range charSeeds {
|
||||||
|
rng := rand.New(rand.NewPCG(seed, 0xC0FFEE))
|
||||||
|
res := simulatePartyWithRNG(sc.seats, sc.enemy, sc.phases, rng)
|
||||||
|
fmt.Fprintf(&b, "=== %s seed=%d ===\n", sc.name, seed)
|
||||||
|
b.WriteString(formatPartyResult(res))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
|
||||||
|
path := filepath.Join("testdata", "party_characterization.golden")
|
||||||
|
if *updatePartyGolden {
|
||||||
|
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log("party golden rewritten:", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read party golden (run with -update-party to create it): %v", err)
|
||||||
|
}
|
||||||
|
if string(want) != got {
|
||||||
|
t.Fatalf("PARTY ENGINE BEHAVIOUR MOVED.\n\n%s\n\n"+
|
||||||
|
"If this was deliberate, say so in the commit and regenerate:\n"+
|
||||||
|
" go test ./internal/plugin -run TestPartyCharacterization -update-party",
|
||||||
|
firstDiff(string(want), got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-seat roster is the solo engine. If this ever fails, the N-body path has
|
||||||
|
// stopped being a superset of the path the entire balance corpus was measured on,
|
||||||
|
// and every baseline in the repo is suspect.
|
||||||
|
func TestPartyCharacterization_OneSeatIsStillSolo(t *testing.T) {
|
||||||
|
for _, seed := range charSeeds {
|
||||||
|
solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases,
|
||||||
|
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
|
||||||
|
party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases,
|
||||||
|
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
|
||||||
|
|
||||||
|
if solo.PlayerWon != party.PlayerWon || solo.TotalRounds != party.TotalRounds ||
|
||||||
|
solo.EnemyEndHP != party.EnemyEndHP || solo.PlayerEndHP != party.Seats[0].PlayerEndHP {
|
||||||
|
t.Fatalf("seed %d: a one-seat roster diverged from solo\n solo: won=%v rounds=%d ehp=%d php=%d\n party: won=%v rounds=%d ehp=%d php=%d",
|
||||||
|
seed,
|
||||||
|
solo.PlayerWon, solo.TotalRounds, solo.EnemyEndHP, solo.PlayerEndHP,
|
||||||
|
party.PlayerWon, party.TotalRounds, party.EnemyEndHP, party.Seats[0].PlayerEndHP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,18 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
|||||||
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
||||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||||
for seat := range sess.RosterSize() {
|
for seat := range sess.RosterSize() {
|
||||||
|
// The companion has no sheet, so none of the sheet-keyed bookkeeping below
|
||||||
|
// applies to him — and postCombatBookkeepingForSeat logs at ERROR for a
|
||||||
|
// seat with no sheet, which would file one for every party fight he is ever
|
||||||
|
// hired for. But his HP is not bookkeeping: it is the fight's result. It
|
||||||
|
// lands on his roster row, because that is the only row he has.
|
||||||
|
//
|
||||||
|
// He used to be skipped outright, and "he arrives fresh next time" was the
|
||||||
|
// stated intent. It is a free lunch — see companionSeatHP.
|
||||||
|
if isCompanionUser(sess.seatUserID(seat)) {
|
||||||
|
_ = setCompanionHPForRun(sess.RunID, sess.seatHP(seat))
|
||||||
|
continue
|
||||||
|
}
|
||||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||||
p.postCombatBookkeepingForSeat(sess, seat)
|
p.postCombatBookkeepingForSeat(sess, seat)
|
||||||
}
|
}
|
||||||
@@ -100,6 +112,16 @@ func (p *AdventurePlugin) finishPartyWin(
|
|||||||
uid := id.UserID(sess.seatUserID(seat))
|
uid := id.UserID(sess.seatUserID(seat))
|
||||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||||
|
|
||||||
|
// The hired companion takes no cut. He earns no XP (there is no sheet to
|
||||||
|
// put it on), rolls no loot (dropZoneLoot writes real inventory rows for
|
||||||
|
// whatever id it is handed, and a bot with a magic sword is nobody's
|
||||||
|
// intent), and takes no death row. His seat renders nothing: he is not
|
||||||
|
// reading this. He was paid up front.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
out[seat] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// A member who went down before the killing blow still earns the kill.
|
// A member who went down before the killing blow still earns the kill.
|
||||||
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
||||||
|
|
||||||
@@ -148,7 +170,17 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
|
|||||||
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
||||||
out := make([]string, sess.RosterSize())
|
out := make([]string, sess.RosterSize())
|
||||||
for seat := range sess.RosterSize() {
|
for seat := range sess.RosterSize() {
|
||||||
markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display)
|
uid := id.UserID(sess.seatUserID(seat))
|
||||||
|
// The companion does not die. markAdventureDead is a silent no-op for him
|
||||||
|
// today (no player_meta row to mark), but relying on that accident is how
|
||||||
|
// he ends up in the graveyard the first time someone gives him a row —
|
||||||
|
// and emitDeathNews would have the news bot file a death notice about
|
||||||
|
// itself. Say it out loud instead.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
out[seat] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
markAdventureDead(uid, "zone", zone.Display)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if line != "" && seat == 0 {
|
if line != "" && seat == 0 {
|
||||||
b.WriteString(line + "\n")
|
b.WriteString(line + "\n")
|
||||||
@@ -220,9 +252,9 @@ func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = abandonZoneRun(owner)
|
_ = abandonZoneRun(owner)
|
||||||
reason := "combat flee"
|
reason := lossCombatFlee
|
||||||
if death {
|
if death {
|
||||||
reason = "combat death"
|
reason = lossCombatDeath
|
||||||
}
|
}
|
||||||
forceExtractExpeditionForRunLoss(owner, reason)
|
forceExtractExpeditionForRunLoss(owner, reason)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ func fightRoster(sender id.UserID) []id.UserID {
|
|||||||
if err != nil || e == nil {
|
if err != nil || e == nil {
|
||||||
return []id.UserID{sender}
|
return []id.UserID{sender}
|
||||||
}
|
}
|
||||||
return expeditionAudience(e)
|
// Seats, not audience: the hired companion fights even though he never
|
||||||
|
// receives a DM about it.
|
||||||
|
return expeditionSeats(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFightSeats turns a roster into the seats that will actually sit down, and
|
// buildFightSeats turns a roster into the seats that will actually sit down, and
|
||||||
@@ -56,9 +58,40 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
senderSkip = why
|
senderSkip = why
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Parallel to `seats`, so a skipped member leaves no gap: what a seat costs the
|
||||||
|
// enemy is priced from these once the roster is final.
|
||||||
|
var levels []int
|
||||||
|
var companions []bool
|
||||||
for i, uid := range roster {
|
for i, uid := range roster {
|
||||||
leader := i == 0
|
leader := i == 0
|
||||||
|
|
||||||
|
// The hired companion. He must be handled before everything below:
|
||||||
|
// dndHPSnapshot returns (0,0) for a user with no sheet, so the very next
|
||||||
|
// check would quietly sit him out of every fight he was paid for, and
|
||||||
|
// buildZoneCombatants would then fail on him anyway.
|
||||||
|
//
|
||||||
|
// He is latched onto autopilot at seat time rather than after the away-player
|
||||||
|
// deadline — nobody is going to type for him, and waiting three minutes to
|
||||||
|
// discover that would stall the fight and then announce him to the party as
|
||||||
|
// an absent player.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
expID := companionExpeditionFor(roster[0])
|
||||||
|
class, level := companionLoadout(expID)
|
||||||
|
player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
||||||
|
// He carries his wounds between fights, like everyone else. Seating him at
|
||||||
|
// full max HP — which is what this did — hands the party an infinite body:
|
||||||
|
// he soaks a share of every fight's incoming and then resets, while the
|
||||||
|
// humans beside him bleed all the way to camp.
|
||||||
|
seats = append(seats, CombatSeatSetup{
|
||||||
|
UserID: uid,
|
||||||
|
HP: companionSeatHP(expID, player.Stats.MaxHP),
|
||||||
|
HPMax: player.Stats.MaxHP,
|
||||||
|
Mods: player.Mods, C: &player, EngineDriven: true,
|
||||||
|
})
|
||||||
|
levels, companions = append(levels, level), append(companions, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Both refusals below are cheap and neither needs the build, so they run
|
// Both refusals below are cheap and neither needs the build, so they run
|
||||||
// before it: consuming a seat's armed ability and *then* sitting them out
|
// before it: consuming a seat's armed ability and *then* sitting them out
|
||||||
// would spend their rage on a fight they never joined.
|
// would spend their rage on a fight they never joined.
|
||||||
@@ -94,7 +127,7 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
trySimAutoArm(c)
|
trySimAutoArm(c)
|
||||||
armed = consumeArmedAbility(c)
|
armed = consumeArmedAbility(c)
|
||||||
}
|
}
|
||||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
player, e, dc, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if leader {
|
if leader {
|
||||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||||
@@ -113,7 +146,17 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
seats = append(seats, CombatSeatSetup{
|
seats = append(seats, CombatSeatSetup{
|
||||||
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
||||||
})
|
})
|
||||||
|
lvl := 0
|
||||||
|
if dc != nil {
|
||||||
|
lvl = dc.Level
|
||||||
|
}
|
||||||
|
levels, companions = append(levels, lvl), append(companions, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Price each seat against the leader. It runs here, over the seats that were
|
||||||
|
// actually seated — a member who was skipped (downed, busy elsewhere) never
|
||||||
|
// joined the fight and must not be charged to the enemy.
|
||||||
|
applySeatWeights(seatCombatants(seats), levels, companions)
|
||||||
return seats, enemy, senderSkip, ""
|
return seats, enemy, senderSkip, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +202,12 @@ func (p *AdventurePlugin) announcePartyFightStart(
|
|||||||
names[i] = c.Name
|
names[i] = c.Name
|
||||||
}
|
}
|
||||||
for seat, uid := range sess.SeatUserIDs() {
|
for seat, uid := range sess.SeatUserIDs() {
|
||||||
|
// Seat-keyed fan-out, so it bypasses expeditionAudience's filter — the
|
||||||
|
// companion sits down but is never written to. (He also has no magic items
|
||||||
|
// to line up, and activeMagicItemsLine would go looking for them.)
|
||||||
|
if isCompanionUser(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(header)
|
b.WriteString(header)
|
||||||
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))
|
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))
|
||||||
|
|||||||
@@ -166,6 +166,15 @@ func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (
|
|||||||
return fail(notYourTurnMsg(players, acting, waiting))
|
return fail(notYourTurnMsg(players, acting, waiting))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An engine-driven seat has nobody to hand the wheel back to. A command
|
||||||
|
// arriving from one is not a player returning to the keyboard — it is a driver
|
||||||
|
// impersonating a seat, which is precisely the confusion that used to strand
|
||||||
|
// the companion: his own auto-played turn came back through here looking like
|
||||||
|
// a keystroke and cleared the latch that was moving him. Refuse it outright.
|
||||||
|
if sess.seatIsEngineDriven(seat) {
|
||||||
|
return fail("That seat isn't yours to play.")
|
||||||
|
}
|
||||||
|
|
||||||
// They typed, so they are here. Hand back the wheel.
|
// They typed, so they are here. Hand back the wheel.
|
||||||
sess.actorStatusesPtr(seat).Autopilot = false
|
sess.actorStatusesPtr(seat).Autopilot = false
|
||||||
|
|
||||||
@@ -315,6 +324,33 @@ func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
|
|||||||
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
|
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// driveEngineSeat plays one engine-driven seat's turn and persists the result.
|
||||||
|
// It is the seat-driver equivalent of a player typing `!attack`, for a seat that
|
||||||
|
// will never type anything.
|
||||||
|
//
|
||||||
|
// It exists because the alternative — dispatching a combat command as that seat —
|
||||||
|
// sends the turn back through beginCombatTurn, which reads any command from a
|
||||||
|
// seat as "that player is back". For a human that is correct. For a seat with no
|
||||||
|
// human it is fatal: it drops the latch that is the only thing moving them.
|
||||||
|
func (p *AdventurePlugin) driveEngineSeat(sess *CombatSession, seat int) error {
|
||||||
|
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rebuild for engine seat: %w", err)
|
||||||
|
}
|
||||||
|
ct := &combatTurn{
|
||||||
|
sess: sess, players: players, enemy: enemy,
|
||||||
|
seat: seat, uid: id.UserID(sess.seatUserID(seat)),
|
||||||
|
}
|
||||||
|
if _, err := p.runAutoSeatTurn(ct, seat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveCombatSession(sess); err != nil {
|
||||||
|
return fmt.Errorf("save after engine seat turn: %w", err)
|
||||||
|
}
|
||||||
|
p.closePartyRound(ct)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
|
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
|
||||||
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
|
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
|
||||||
// save that parked the fight on this seat's player_turn was the last one — so it
|
// save that parked the fight on this seat's player_turn was the last one — so it
|
||||||
@@ -387,6 +423,12 @@ func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEven
|
|||||||
names := ct.seatNames()
|
names := ct.seatNames()
|
||||||
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
||||||
for seat, uid := range ct.sess.SeatUserIDs() {
|
for seat, uid := range ct.sess.SeatUserIDs() {
|
||||||
|
// The combat fan-out is seat-keyed, so it does not pass through
|
||||||
|
// expeditionAudience and does not inherit its companion filter. He fights;
|
||||||
|
// he is not written to about it.
|
||||||
|
if isCompanionUser(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Rendered once per reader: the flavor pool speaks in the second person,
|
// Rendered once per reader: the flavor pool speaks in the second person,
|
||||||
// so each member's own events must be theirs and nobody else's.
|
// so each member's own events must be theirs and nobody else's.
|
||||||
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
|
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
|
||||||
|
|||||||
174
internal/plugin/combat_seat_spells.go
Normal file
174
internal/plugin/combat_seat_spells.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seat-scoped spellbook.
|
||||||
|
//
|
||||||
|
// Every spell lookup in the engine — known list, prepared flag, slot pool, slot
|
||||||
|
// spend — is keyed on a Matrix user id and answered by a `dnd_*` table. That was
|
||||||
|
// fine while every combatant was a player. The hired companion is not: he has no
|
||||||
|
// row in dnd_character, dnd_known_spells or dnd_spell_slots, deliberately, because
|
||||||
|
// a sheet on disk for him is the thing that would turn him into a real character
|
||||||
|
// everywhere (player_meta, the leaderboard, !stats). His sheet is synthesized per
|
||||||
|
// fight and thrown away.
|
||||||
|
//
|
||||||
|
// So every one of those lookups returned "nothing" for him, and the picker's very
|
||||||
|
// first line — `c, _ := LoadDnDCharacter(uid); if c == nil { return "attack" }` —
|
||||||
|
// sent him to swing a mace, every turn, forever. A hired Cleric could not heal.
|
||||||
|
// Role-fill hands a lone fighter a Cleric, so that was the *common* case.
|
||||||
|
//
|
||||||
|
// These are the seat-scoped forms of those lookups. A human seat delegates to the
|
||||||
|
// DB functions verbatim — same queries, same order, so solo combat and the balance
|
||||||
|
// corpus are untouched. A companion seat is answered from his in-memory sheet and
|
||||||
|
// a slot ledger on his seat's persisted statuses.
|
||||||
|
//
|
||||||
|
// Nothing here may write a row for the companion. That invariant is what
|
||||||
|
// TestCompanion_SheetIsBelowMedianAndNeverPersisted pins, and the auto-migration
|
||||||
|
// inside ensureCharForDnDCmd would violate it silently: handed a user with no
|
||||||
|
// sheet, it *builds one at level 1 and saves it*. Hence seatCastSheet.
|
||||||
|
|
||||||
|
// seatCompanionLoadout returns the class and level a companion seat fights as,
|
||||||
|
// and whether the seat is the companion at all.
|
||||||
|
func seatCompanionLoadout(sess *CombatSession, uid id.UserID) (DnDClass, int, bool) {
|
||||||
|
if sess == nil || !isCompanionSeat(uid) {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
class, level := companionLoadoutForRun(sess.RunID)
|
||||||
|
return class, level, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatCastSheet resolves the character behind a seat for the !cast path.
|
||||||
|
//
|
||||||
|
// A human goes through ensureCharForDnDCmd, exactly as before — including its
|
||||||
|
// auto-migration for a player who predates Adv 2.0. The companion must NOT: that
|
||||||
|
// migration would mint and persist a level-1 dnd_character row for him, quietly
|
||||||
|
// making him a player and throwing away the class and level he was hired at. He
|
||||||
|
// gets his synthetic sheet instead, built from the same class-priority pipeline a
|
||||||
|
// real character of his level uses.
|
||||||
|
func (p *AdventurePlugin) seatCastSheet(sess *CombatSession, uid id.UserID) (*DnDCharacter, error) {
|
||||||
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
return companionSheet(class, level), nil
|
||||||
|
}
|
||||||
|
advChar, _ := loadAdvCharacter(uid)
|
||||||
|
return p.ensureCharForDnDCmd(uid, advChar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatPickSheet is seatCastSheet for the auto-picker, which reads a sheet to
|
||||||
|
// decide a turn and must never create one. Humans get LoadDnDCharacter, which is
|
||||||
|
// what the picker has always called; a miss returns nil and the caller swings.
|
||||||
|
func seatPickSheet(sess *CombatSession, uid id.UserID) *DnDCharacter {
|
||||||
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
return companionSheet(class, level)
|
||||||
|
}
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionKnownSpells is the companion's spell list: the same default kit
|
||||||
|
// ensureSpellsForCharacter grants a real character of that class and level, every
|
||||||
|
// entry prepared (which is also what that function does — preparation is SP4 and
|
||||||
|
// until it lands every granted spell is auto-prepared so !cast works).
|
||||||
|
//
|
||||||
|
// He gets the player kit on purpose. His below-median comes from the level penalty,
|
||||||
|
// the never-Masterwork gear and the absent subclass/magic items — the layers a
|
||||||
|
// player accumulates. Handing him a bespoke, weaker spell list would be a second
|
||||||
|
// nerf hidden in a different file, and it would drift away from the tuned list the
|
||||||
|
// moment anyone touched one and not the other.
|
||||||
|
func companionKnownSpells(class DnDClass, level int) []knownSpellRow {
|
||||||
|
ids := defaultKnownSpells(class, level)
|
||||||
|
out := make([]knownSpellRow, 0, len(ids))
|
||||||
|
for _, sid := range ids {
|
||||||
|
out = append(out, knownSpellRow{SpellID: sid, Source: "companion", Prepared: true})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionSlotPool is his slot table (total, used) — the class/level progression
|
||||||
|
// every caster shares, less what he has already spent. `used` is the expedition's
|
||||||
|
// ledger, NOT a per-fight one: he rations one pool across the run and gets it back
|
||||||
|
// at camp, exactly as a human caster does.
|
||||||
|
func companionSlotPool(class DnDClass, level int, used [6]int) map[int][2]int {
|
||||||
|
out := map[int][2]int{}
|
||||||
|
for lvl, total := range slotsForClassLevel(class, level) {
|
||||||
|
spent := 0
|
||||||
|
if lvl >= 0 && lvl < len(used) {
|
||||||
|
spent = min(used[lvl], total)
|
||||||
|
}
|
||||||
|
out[lvl] = [2]int{total, spent}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatKnownSpells is listKnownSpells for a seat.
|
||||||
|
func seatKnownSpells(sess *CombatSession, seat int, uid id.UserID) ([]knownSpellRow, error) {
|
||||||
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
return companionKnownSpells(class, level), nil
|
||||||
|
}
|
||||||
|
return listKnownSpells(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatSpellSlots is getSpellSlots for a seat.
|
||||||
|
func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) {
|
||||||
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
return companionSlotPool(class, level, companionSlotsForRun(sess.RunID)), nil
|
||||||
|
}
|
||||||
|
return getSpellSlots(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatKnowsSpell is playerKnowsSpell for a seat: (known, prepared, err).
|
||||||
|
func seatKnowsSpell(sess *CombatSession, seat int, uid id.UserID, spellID string) (bool, bool, error) {
|
||||||
|
if _, _, ok := seatCompanionLoadout(sess, uid); ok {
|
||||||
|
known, err := seatKnownSpells(sess, seat, uid)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
for _, k := range known {
|
||||||
|
if k.SpellID == spellID {
|
||||||
|
return true, k.Prepared, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false, nil
|
||||||
|
}
|
||||||
|
return playerKnowsSpell(uid, spellID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeSeatSlot is consumeSpellSlot for a seat. The companion's spend lands on
|
||||||
|
// the expedition's ledger — one pool for the whole run, refilled at camp — so he
|
||||||
|
// rations his slots the way a human caster has to. Keying it by expedition also
|
||||||
|
// keeps two parties who have each hired him from sharing a pool, which anything
|
||||||
|
// keyed on his (single, shared) user id would have done.
|
||||||
|
func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) {
|
||||||
|
class, level, ok := seatCompanionLoadout(sess, uid)
|
||||||
|
if !ok {
|
||||||
|
return consumeSpellSlot(uid, slotLevel)
|
||||||
|
}
|
||||||
|
used := companionSlotsForRun(sess.RunID)
|
||||||
|
if slotLevel < 1 || slotLevel >= len(used) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
pair, exists := companionSlotPool(class, level, used)[slotLevel]
|
||||||
|
if !exists || pair[0]-pair[1] <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
used[slotLevel]++
|
||||||
|
if err := setCompanionSlotsForRun(sess.RunID, used); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundSeatSlot is refundSpellSlot for a seat: the rollback half of the above,
|
||||||
|
// called when the round the slot was spent on failed to resolve.
|
||||||
|
func refundSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) error {
|
||||||
|
if _, _, ok := seatCompanionLoadout(sess, uid); !ok {
|
||||||
|
return refundSpellSlot(uid, slotLevel)
|
||||||
|
}
|
||||||
|
used := companionSlotsForRun(sess.RunID)
|
||||||
|
if slotLevel < 1 || slotLevel >= len(used) || used[slotLevel] <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
used[slotLevel]--
|
||||||
|
return setCompanionSlotsForRun(sess.RunID, used)
|
||||||
|
}
|
||||||
277
internal/plugin/combat_seat_spells_test.go
Normal file
277
internal/plugin/combat_seat_spells_test.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The hired companion casts.
|
||||||
|
//
|
||||||
|
// He could not, and the reason was one line: every spell lookup in the engine is
|
||||||
|
// keyed on a user id and answered by a dnd_* table, and he has no rows in any of
|
||||||
|
// them — deliberately, because a sheet on disk is what would turn him into a real
|
||||||
|
// character everywhere. So the picker's first statement (`LoadDnDCharacter(uid)`)
|
||||||
|
// came back nil and returned "attack", every turn, for the whole fight.
|
||||||
|
//
|
||||||
|
// Role-fill hands a lone martial a Cleric. So the common case of the feature was a
|
||||||
|
// healer who could not heal, in a party engine that had only just learned to let
|
||||||
|
// anyone heal anyone (§1). These pin both halves: that he picks the heal, and that
|
||||||
|
// picking it leaves no trace of him in the database.
|
||||||
|
|
||||||
|
// hireForFight seeds an expedition with the companion hired into it, and returns
|
||||||
|
// the run id his loadout is resolved against.
|
||||||
|
func hireForFight(t *testing.T, expID string, owner id.UserID, class DnDClass, level int) string {
|
||||||
|
t.Helper()
|
||||||
|
seedExpedition(t, expID, owner, "active")
|
||||||
|
seatLeaderFixture(t, expID)
|
||||||
|
if err := hireCompanion(expID, class, level); err != nil {
|
||||||
|
t.Fatalf("hireCompanion: %v", err)
|
||||||
|
}
|
||||||
|
return "run-" + expID
|
||||||
|
}
|
||||||
|
|
||||||
|
// petePartyFight seats a hurt leader and the companion, and returns the turn.
|
||||||
|
func petePartyFight(t *testing.T, p *AdventurePlugin, runID string, leader id.UserID, leaderHP int) *combatTurn {
|
||||||
|
t.Helper()
|
||||||
|
monster := dndBestiary["goblin"]
|
||||||
|
lead, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
|
||||||
|
lead.Name = "lead"
|
||||||
|
pete, _, _ := p.companionCombatant(ClassCleric, 6, monster, 2, 0)
|
||||||
|
pete.Name = companionDisplayName
|
||||||
|
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
|
||||||
|
|
||||||
|
sess, err := p.startPartyCombatSession(runID, "enc", "goblin", &enemy, []CombatSeatSetup{
|
||||||
|
{UserID: leader, HP: leaderHP, HPMax: 100, Mods: lead.Mods, C: &lead},
|
||||||
|
{UserID: companionUserID(), HP: 60, HPMax: 60, Mods: pete.Mods, C: &pete, EngineDriven: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &combatTurn{
|
||||||
|
sess: sess, players: []*Combatant{&lead, &pete}, enemy: &enemy,
|
||||||
|
seat: 1, uid: companionUserID(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The headline: a hired Cleric, watching the leader bleed out, casts a heal on him.
|
||||||
|
// Before the seat-scoped spellbook this returned ("attack", "") — he swung a mace
|
||||||
|
// at the boss while the man who paid for him died.
|
||||||
|
func TestCompanionSpells_HiredClericHealsTheLeader(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-heal", leader, ClassCleric, 6)
|
||||||
|
ct := petePartyFight(t, p, runID, leader, 20) // leader at 20/100 — well under the 45% bar
|
||||||
|
|
||||||
|
kind, arg := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
|
||||||
|
if kind != "cast" {
|
||||||
|
t.Fatalf("the hired cleric picked %q %q with the leader at 20%% HP — he must heal", kind, arg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(arg, "@lead") {
|
||||||
|
t.Fatalf("cast arg = %q, want the heal aimed at the leader's seat", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it lands on the leader, not on himself.
|
||||||
|
action, settle, msg := p.castActionForSeat(ct, 1, arg)
|
||||||
|
if msg != "" {
|
||||||
|
t.Fatalf("castActionForSeat refused the companion's own pick: %s", msg)
|
||||||
|
}
|
||||||
|
settle(true)
|
||||||
|
eff := action.Effect
|
||||||
|
if eff == nil || eff.AllyHeal <= 0 {
|
||||||
|
t.Fatalf("effect = %+v, want an ally heal", eff)
|
||||||
|
}
|
||||||
|
if eff.AllySeat != 0 {
|
||||||
|
t.Errorf("heal landed on seat %d, want seat 0 (the leader)", eff.AllySeat)
|
||||||
|
}
|
||||||
|
if eff.PlayerHeal != 0 {
|
||||||
|
t.Errorf("the heal also healed the caster (%d HP) — it was redirected, not copied", eff.PlayerHeal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// His slots are spent on his seat, and he leaves no rows behind.
|
||||||
|
//
|
||||||
|
// This is the invariant with teeth. castActionForSeat used to load the caster via
|
||||||
|
// ensureCharForDnDCmd, whose auto-migration branch — handed a user with no sheet —
|
||||||
|
// *builds one at level 1 and saves it*. Pointed at the companion that silently
|
||||||
|
// makes him a player: a dnd_character row, a player_meta seed, a spellbook, and a
|
||||||
|
// level-1 chassis in place of the level he was hired at.
|
||||||
|
func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-rows", leader, ClassCleric, 6)
|
||||||
|
ct := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
|
||||||
|
action, settle, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead")
|
||||||
|
if msg != "" {
|
||||||
|
t.Fatalf("the hired cleric could not cast cure wounds: %s", msg)
|
||||||
|
}
|
||||||
|
settle(true)
|
||||||
|
if action.Effect == nil || action.Effect.AllyHeal <= 0 {
|
||||||
|
t.Fatalf("effect = %+v, want an ally heal", action.Effect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slot came off the expedition's ledger — which is where it has to live, so
|
||||||
|
// that it is still spent in the NEXT fight of the same run.
|
||||||
|
if used := companionSlotsForRun(ct.sess.RunID); used[1] != 1 {
|
||||||
|
t.Errorf("companion spent %v level-1 slots on the run's ledger, want 1", used[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range []string{"dnd_character", "dnd_known_spells", "dnd_spell_slots", "player_meta"} {
|
||||||
|
var n int
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM `+table+` WHERE user_id = ?`, string(companionUserID()),
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count %s: %v", table, err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("casting wrote %d %s row(s) for the companion — he is not a player", n, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// He runs dry like anybody else, and then he swings. A companion with an infinite
|
||||||
|
// spell pool is the "carry" the whole design says he must never be.
|
||||||
|
func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6)
|
||||||
|
ct := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
|
||||||
|
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
|
||||||
|
if total <= 0 {
|
||||||
|
t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve")
|
||||||
|
}
|
||||||
|
for i := range total {
|
||||||
|
if ok, err := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil || !ok {
|
||||||
|
t.Fatalf("slot %d/%d: consume = %v (%v), want it to succeed", i+1, total, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); ok {
|
||||||
|
t.Fatalf("the companion cast a %d-th level-1 spell out of a %d-slot pool", total+1, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refund half: a round that fails to resolve gives the slot back.
|
||||||
|
if err := refundSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); !ok {
|
||||||
|
t.Error("a refunded slot was not castable again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// His pool does NOT refill between fights, and it DOES come back at camp.
|
||||||
|
//
|
||||||
|
// This is the one the sweep caught and the unit tests did not. The first cut of
|
||||||
|
// the spellbook parked his ledger on his combat seat — and a seat is per-session,
|
||||||
|
// so every fight opened a fresh one and he walked in with full slots. A human
|
||||||
|
// cleric rations a single pool across the whole run; rationing it IS the caster's
|
||||||
|
// game. Handed an infinite pool, a gearless level-penalized hireling out-cleared a
|
||||||
|
// same-level human cleric by 15pp in the sim.
|
||||||
|
func TestCompanionSpells_PoolIsRationedAcrossTheRun(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-ration", leader, ClassCleric, 6)
|
||||||
|
|
||||||
|
// Fight one: spend every level-1 slot he owns.
|
||||||
|
first := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
|
||||||
|
for range total {
|
||||||
|
if ok, err := consumeSeatSlot(first.sess, 1, companionUserID(), 1); err != nil || !ok {
|
||||||
|
t.Fatalf("consume: %v (%v)", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fight two, same run — a NEW combat session, which is exactly what used to
|
||||||
|
// hand him a fresh pool.
|
||||||
|
if err := markCombatSessionExpired(first.sess.SessionID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); ok {
|
||||||
|
t.Error("the companion's spell slots refilled between fights — he is an infinite caster")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and camp gives them back, the same way it does for every human.
|
||||||
|
if err := refreshCompanionSlots("exp-ration"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); !ok {
|
||||||
|
t.Error("camp did not restore the companion's slots — his pool only ever goes down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// He carries his wounds between fights, and camp patches him up.
|
||||||
|
//
|
||||||
|
// He used to re-seat at full max HP for every single fight — "he arrives fresh
|
||||||
|
// next time" was the close-out's stated intent — which is an infinite body. A
|
||||||
|
// player bleeds across a 30-room run and only heals at camp; the hireling soaked
|
||||||
|
// his share of every fight and then reset. In the sim his party fled 5 runs out of
|
||||||
|
// 640 where the same party with a *human* cleric fled 56.
|
||||||
|
func TestCompanion_CarriesWoundsBetweenFights(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
seedExpedition(t, "exp-hp", leader, "active")
|
||||||
|
seatLeaderFixture(t, "exp-hp")
|
||||||
|
if err := hireCompanion("exp-hp", ClassCleric, 6); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh hire is at full.
|
||||||
|
if got := companionSeatHP("exp-hp", 100); got != 100 {
|
||||||
|
t.Errorf("a fresh hire seats at %d/100 HP, want full", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// He walks out of a fight on 30 HP; he walks into the next one on 30 HP.
|
||||||
|
if err := setCompanionHP("exp-hp", 30); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := companionSeatHP("exp-hp", 100); got != 30 {
|
||||||
|
t.Errorf("he re-seated at %d/100 HP after ending a fight on 30 — the wound did not carry", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropped in a fight, he comes back on his feet but barely — not as a corpse
|
||||||
|
// (there is no companion-death rule) and not at full.
|
||||||
|
if err := setCompanionHP("exp-hp", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := companionSeatHP("exp-hp", 100); got != 1 {
|
||||||
|
t.Errorf("after being dropped he seats at %d/100 HP, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camp puts him right, exactly as it does every human.
|
||||||
|
if err := refreshCompanionHP("exp-hp"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := companionSeatHP("exp-hp", 100); got != 100 {
|
||||||
|
t.Errorf("camp left him on %d/100 HP — his body only ever goes down", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hired martial is still a martial: the spellbook is the class's, not a blanket
|
||||||
|
// grant, so hiring a Fighter does not quietly buy a caster.
|
||||||
|
func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
leader := id.UserID("@lead:example.org")
|
||||||
|
runID := hireForFight(t, "exp-mart", leader, ClassFighter, 6)
|
||||||
|
ct := petePartyFight(t, p, runID, leader, 20)
|
||||||
|
|
||||||
|
if known, _ := seatKnownSpells(ct.sess, 1, companionUserID()); len(known) != 0 {
|
||||||
|
t.Errorf("a hired Fighter knows %d spells, want none", len(known))
|
||||||
|
}
|
||||||
|
if _, _, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead"); msg == "" {
|
||||||
|
t.Error("a hired Fighter cast cure wounds")
|
||||||
|
}
|
||||||
|
kind, _ := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
|
||||||
|
if kind != "attack" {
|
||||||
|
t.Errorf("a hired Fighter picked %q, want attack", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
internal/plugin/combat_seat_weight_test.go
Normal file
108
internal/plugin/combat_seat_weight_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// §2(a) — the enemy charges for what a seat BRINGS, not for the fact that it sat
|
||||||
|
// down.
|
||||||
|
//
|
||||||
|
// Both scaling levers (the +15% boss HP and the 1 → 2.4 attacks-a-round action
|
||||||
|
// economy) counted seats. A seat count charges the same for an under-levelled
|
||||||
|
// friend, a hired NPC, and a true peer — so a below-median body cost a full seat's
|
||||||
|
// worth of boss and did not give a full seat's worth back. Measured: hiring the
|
||||||
|
// companion was *worse than going alone* (66.1% against solo's 69.0%) once his
|
||||||
|
// free full-heal was taken away.
|
||||||
|
//
|
||||||
|
// The invariant that makes this safe to ship: every seat that IS a peer still
|
||||||
|
// weighs exactly 1.0, so solo and a party of equals are byte-identical and the
|
||||||
|
// balance corpus does not move. Only an unequal roster lands between the knots.
|
||||||
|
|
||||||
|
func TestSeatWeight_APeerWeighsExactlyOne(t *testing.T) {
|
||||||
|
// The leader, and a friend of the leader's level: both full price.
|
||||||
|
if got := seatWeight(10, 10, false); got != 1.0 {
|
||||||
|
t.Errorf("a peer weighs %v, want exactly 1.0 — a party of equals must not move", got)
|
||||||
|
}
|
||||||
|
// Out-levelling the leader does not make the boss harder for everybody else.
|
||||||
|
if got := seatWeight(14, 10, false); got != 1.0 {
|
||||||
|
t.Errorf("a higher-level friend weighs %v, want 1.0 (capped)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeatWeight_TheUnderLevelledAndTheHiredCostLess(t *testing.T) {
|
||||||
|
// The under-levelled friend — the case that has nothing to do with the
|
||||||
|
// companion and has been live since parties shipped.
|
||||||
|
half := seatWeight(5, 10, false)
|
||||||
|
if half >= 1.0 || half <= seatWeightFloor {
|
||||||
|
t.Errorf("a level-5 friend of a level-10 leader weighs %v, want between the floor and 1.0", half)
|
||||||
|
}
|
||||||
|
// The hireling pays the same level penalty AND the discount for everything a
|
||||||
|
// player accrues that he never will (subclass, magic items, Masterwork gear).
|
||||||
|
hired := seatWeight(9, 10, true)
|
||||||
|
peerAtSameLevel := seatWeight(9, 10, false)
|
||||||
|
if hired >= peerAtSameLevel {
|
||||||
|
t.Errorf("the hireling weighs %v and a human of his level weighs %v — he must cost the enemy less",
|
||||||
|
hired, peerAtSameLevel)
|
||||||
|
}
|
||||||
|
// But a body is never free: it is still one more thing the boss has to kill.
|
||||||
|
if got := seatWeight(1, 20, true); got < seatWeightFloor {
|
||||||
|
t.Errorf("a hopelessly under-levelled hireling weighs %v, below the floor %v", got, seatWeightFloor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The levers themselves: integer knots byte-exact, fractions in between.
|
||||||
|
func TestSeatWeight_ScalingIsExactAtThePeerKnots(t *testing.T) {
|
||||||
|
// Solo and a party of peers reproduce the pre-§2(a) numbers exactly. This is
|
||||||
|
// the whole safety argument — if either of these drifts, the corpus is invalid.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
weight float64
|
||||||
|
acts float64
|
||||||
|
hp float64
|
||||||
|
}{
|
||||||
|
{1, 1, 1.0}, // solo
|
||||||
|
{2, 2.4, 1.15}, // a duo of peers
|
||||||
|
{3, 5, 1.15}, // a trio of peers
|
||||||
|
} {
|
||||||
|
if got := partyActionExpectation(tc.weight); got != tc.acts {
|
||||||
|
t.Errorf("weight %v: enemy actions = %v, want exactly %v", tc.weight, got, tc.acts)
|
||||||
|
}
|
||||||
|
if got := partyEnemyHPScale(tc.weight); got != tc.hp {
|
||||||
|
t.Errorf("weight %v: enemy HP scale = %v, want exactly %v", tc.weight, got, tc.hp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A leader plus a hireling is strictly cheaper than a leader plus a peer, and
|
||||||
|
// strictly dearer than soloing. Bringing him must cost the boss *something* —
|
||||||
|
// that is what stops a free body from becoming a carry.
|
||||||
|
duoWithHire := 1 + seatWeight(9, 10, true)
|
||||||
|
if a := partyActionExpectation(duoWithHire); a <= 1 || a >= 2.4 {
|
||||||
|
t.Errorf("leader + hireling buys the enemy %v actions, want strictly between 1 and 2.4", a)
|
||||||
|
}
|
||||||
|
if h := partyEnemyHPScale(duoWithHire); h <= 1.0 || h >= 1.15 {
|
||||||
|
t.Errorf("leader + hireling scales boss HP by %v, want strictly between 1.0 and 1.15", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seat that is DOWN buys the enemy nothing. §2(b) fixed the head-count half of
|
||||||
|
// this; the weight half has to hold too, or a corpse keeps paying for swings.
|
||||||
|
func TestSeatWeight_TheDeadBuyTheEnemyNothing(t *testing.T) {
|
||||||
|
alive := &Combatant{SeatWeight: 1}
|
||||||
|
hire := &Combatant{SeatWeight: 0.6}
|
||||||
|
st := &combatState{actors: []*actor{
|
||||||
|
{c: alive, playerHP: 40},
|
||||||
|
{c: hire, playerHP: 0}, // dropped
|
||||||
|
}}
|
||||||
|
if got := livingWeight(st); got != 1 {
|
||||||
|
t.Errorf("living weight with a downed hireling = %v, want 1 (only the survivor pays)", got)
|
||||||
|
}
|
||||||
|
st.actors[1].playerHP = 20
|
||||||
|
if got := livingWeight(st); got != 1.6 {
|
||||||
|
t.Errorf("living weight with the hireling up = %v, want 1.6", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unset weight reads as a full peer, so every combatant built before this
|
||||||
|
// existed — and every test that builds one by hand — is priced exactly as before.
|
||||||
|
func TestSeatWeight_UnsetIsAPeer(t *testing.T) {
|
||||||
|
if got := combatantWeight(&Combatant{}); got != 1 {
|
||||||
|
t.Errorf("a combatant with no weight set counts %v, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ type ActorStatuses struct {
|
|||||||
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
|
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
|
||||||
// Without the latch an away member taxes the party the full deadline every
|
// Without the latch an away member taxes the party the full deadline every
|
||||||
// single round; with it, they cost one wait and then resolve instantly. Any
|
// single round; with it, they cost one wait and then resolve instantly. Any
|
||||||
// combat command from that member clears it.
|
// combat command from that member clears it — they typed, so they are back.
|
||||||
//
|
//
|
||||||
// Like the Buff* deltas it is session-layer state with no combatState
|
// Like the Buff* deltas it is session-layer state with no combatState
|
||||||
// counterpart, so snapshotActor carries it over from the prior snapshot
|
// counterpart, so snapshotActor carries it over from the prior snapshot
|
||||||
@@ -129,6 +129,24 @@ type ActorStatuses struct {
|
|||||||
// fight has no turn deadline, only the 1h session reaper.
|
// fight has no turn deadline, only the 1h session reaper.
|
||||||
Autopilot bool `json:"autopilot,omitempty"`
|
Autopilot bool `json:"autopilot,omitempty"`
|
||||||
|
|
||||||
|
// EngineDriven marks a seat that has NO human behind it and never will: a
|
||||||
|
// hired companion today, an NPC ally or a Pete-led expedition tomorrow. It is
|
||||||
|
// the answer to "who owns this turn", and it is deliberately NOT the same
|
||||||
|
// question as Autopilot.
|
||||||
|
//
|
||||||
|
// Autopilot means "a human is away"; it is provisional, and a keystroke ends
|
||||||
|
// it. EngineDriven means "there is nobody to come back", and nothing clears it.
|
||||||
|
// Collapsing the two is a bug with teeth: the expedition autopilot drives a
|
||||||
|
// party by dispatching each seat's turn as a command from that seat, so an
|
||||||
|
// engine seat's own auto-played move arrived back at beginCombatTurn looking
|
||||||
|
// exactly like a player returning to the keyboard, and cleared the very latch
|
||||||
|
// that was moving it. The seat then stood in the fight doing nothing for the
|
||||||
|
// rest of the fight — while the enemy it had inflated by 15% killed the party.
|
||||||
|
//
|
||||||
|
// So the property lives on the seat, not on an identity check, and no command
|
||||||
|
// path can unset it.
|
||||||
|
EngineDriven bool `json:"engine_driven,omitempty"`
|
||||||
|
|
||||||
// Debuffs the enemy has stacked onto this character specifically.
|
// Debuffs the enemy has stacked onto this character specifically.
|
||||||
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||||
@@ -256,7 +274,8 @@ func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
|||||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||||
st.HealChargesLeft = playerMods.HealItemCharges
|
st.HealChargesLeft = playerMods.HealItemCharges
|
||||||
st.ArmedAbility = seat.ArmedAbility
|
st.ArmedAbility = seat.ArmedAbility
|
||||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
st.EngineDriven = seat.EngineDriven
|
||||||
|
return st.EngineDriven || st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
||||||
st.ArmedAbility != ""
|
st.ArmedAbility != ""
|
||||||
}
|
}
|
||||||
@@ -387,16 +406,30 @@ func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
|
|||||||
// turn silently rather than blocking the round on a corpse.
|
// turn silently rather than blocking the round on a corpse.
|
||||||
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
||||||
|
|
||||||
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
|
// seatIsEngineDriven reports whether a seat has no human behind it at all — a
|
||||||
// by a lapsed turn deadline.
|
// hired companion, an NPC ally. Permanent for the life of the fight; no command
|
||||||
|
// clears it. Contrast seatIsAutopiloted, which is a human who stepped away.
|
||||||
|
func (s *CombatSession) seatIsEngineDriven(seat int) bool {
|
||||||
|
return s.actorStatusesForSeat(seat).EngineDriven
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatIsAutopiloted reports whether the engine, rather than a person, decides
|
||||||
|
// this seat's action — because its human is away (a lapsed turn deadline) or
|
||||||
|
// because it never had one.
|
||||||
|
//
|
||||||
|
// Every driver in the codebase reads this, which is exactly why the two reasons
|
||||||
|
// share one accessor: the picker does not care WHY nobody is typing. What differs
|
||||||
|
// is who is allowed to take the wheel back, and that question is asked in
|
||||||
|
// beginCombatTurn against seatIsEngineDriven — never here.
|
||||||
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
|
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
|
||||||
return s.actorStatusesForSeat(seat).Autopilot
|
st := s.actorStatusesForSeat(seat)
|
||||||
|
return st.Autopilot || st.EngineDriven
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
|
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
|
||||||
// waiting on its player: it is down (forfeits silently) or latched onto
|
// waiting on its player: it is down (forfeits silently), latched onto autopilot,
|
||||||
// autopilot. driveCombatRound keeps stepping while this holds, so a round only
|
// or has no human to wait for. driveCombatRound keeps stepping while this holds,
|
||||||
// comes to rest on a live human's turn.
|
// so a round only comes to rest on a live human's turn.
|
||||||
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
||||||
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
||||||
}
|
}
|
||||||
@@ -491,7 +524,7 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
|||||||
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
||||||
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
||||||
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
||||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
|
||||||
owner := seats[0]
|
owner := seats[0]
|
||||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||||
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
||||||
@@ -553,6 +586,12 @@ type CombatSeatSetup struct {
|
|||||||
// if they armed nothing. It is persisted onto the seat's statuses so every
|
// if they armed nothing. It is persisted onto the seat's statuses so every
|
||||||
// later rebuild can re-apply the ability without re-spending it.
|
// later rebuild can re-apply the ability without re-spending it.
|
||||||
ArmedAbility string
|
ArmedAbility string
|
||||||
|
// EngineDriven seats this combatant with no human behind it — the hired
|
||||||
|
// companion today. It resolves from the opening round rather than after the
|
||||||
|
// 3-minute away-player deadline (nobody is coming, so waiting out a deadline
|
||||||
|
// would idle the fight and then announce him to the party as absent), and no
|
||||||
|
// command can hand the wheel back to a player who does not exist.
|
||||||
|
EngineDriven bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||||
|
|||||||
@@ -154,13 +154,36 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
|||||||
|
|
||||||
seats := sess.SeatUserIDs()
|
seats := sess.SeatUserIDs()
|
||||||
players := make([]*Combatant, len(seats))
|
players := make([]*Combatant, len(seats))
|
||||||
|
levels := make([]int, len(seats))
|
||||||
|
companions := make([]bool, len(seats))
|
||||||
var enemy Combatant
|
var enemy Combatant
|
||||||
for seat, uid := range seats {
|
for seat, uid := range seats {
|
||||||
// The ability this seat armed was consumed once, at fight start, and its
|
// The ability this seat armed was consumed once, at fight start, and its
|
||||||
// id parked on their statuses. Re-applying it here — not re-consuming —
|
// id parked on their statuses. Re-applying it here — not re-consuming —
|
||||||
// is what makes a rage last the whole fight instead of one round.
|
// is what makes a rage last the whole fight instead of one round.
|
||||||
st := sess.actorStatusesForSeat(seat)
|
st := sess.actorStatusesForSeat(seat)
|
||||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
|
||||||
|
// The hired companion has no sheet to load — by design, because a
|
||||||
|
// player_meta row for him is the thing that would turn him into a real
|
||||||
|
// character everywhere. Synthesize his seat instead. This branch is
|
||||||
|
// load-bearing: buildZoneCombatants would fail on him, and one
|
||||||
|
// unbuildable seat fails the whole rebuild, which is what every human's
|
||||||
|
// !attack in the fight depends on.
|
||||||
|
if isCompanionUser(uid) {
|
||||||
|
class, level := companionLoadoutForRun(sess.RunID)
|
||||||
|
player, en, _ := p.companionCombatant(class, level, monster, int(zone.Tier), run.DMMood)
|
||||||
|
applySessionBuffs(&player, st)
|
||||||
|
players[seat] = &player
|
||||||
|
levels[seat], companions[seat] = level, true
|
||||||
|
if seat == 0 {
|
||||||
|
// Unreachable today (seat 0 is always the leader), but if it ever
|
||||||
|
// isn't, the enemy still has to come from somewhere.
|
||||||
|
enemy = en
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
player, e, dc, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||||
}
|
}
|
||||||
@@ -171,22 +194,47 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
|||||||
// stat deltas are applied here — and only that seat's own.
|
// stat deltas are applied here — and only that seat's own.
|
||||||
applySessionBuffs(&player, st)
|
applySessionBuffs(&player, st)
|
||||||
players[seat] = &player
|
players[seat] = &player
|
||||||
|
if dc != nil {
|
||||||
|
levels[seat] = dc.Level
|
||||||
|
}
|
||||||
if seat == 0 {
|
if seat == 0 {
|
||||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||||
// Only the *player* half of the build varies by seat.
|
// Only the *player* half of the build varies by seat.
|
||||||
enemy = e
|
enemy = e
|
||||||
// Party-only enemy HP bump, re-derived each turn from the template so
|
|
||||||
// it never compounds. Matches the scalar startPartyCombatSession used
|
|
||||||
// for the initial persist; solo (roster 1) scales by 1.0.
|
|
||||||
if sess.IsParty() {
|
|
||||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What each seat costs the enemy, priced against the leader. This runs after
|
||||||
|
// the loop and not inside it, because a seat's weight is relative to seat 0's
|
||||||
|
// level and the enemy's HP is scaled off the *summed* weight — neither is known
|
||||||
|
// until every seat is built.
|
||||||
|
applySeatWeights(players, levels, companions)
|
||||||
|
|
||||||
|
// Party-only enemy HP bump, re-derived each turn from the template so it never
|
||||||
|
// compounds. Matches the scalar startPartyCombatSession used for the initial
|
||||||
|
// persist; solo (one seat, weight 1) scales by 1.0.
|
||||||
|
if sess.IsParty() {
|
||||||
|
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeight(players))
|
||||||
|
}
|
||||||
return players, &enemy, nil
|
return players, &enemy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySeatWeights prices every seat against the leader's level. Seat 0 is the
|
||||||
|
// leader and always weighs a full 1.0.
|
||||||
|
func applySeatWeights(players []*Combatant, levels []int, companions []bool) {
|
||||||
|
if len(players) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
leaderLevel := levels[0]
|
||||||
|
for i, c := range players {
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.SeatWeight = seatWeight(levels[i], leaderLevel, companions[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
|
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
|
||||||
// needs: the Berserker's rage flag, which decides whether the character owes a
|
// needs: the Berserker's rage flag, which decides whether the character owes a
|
||||||
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ type turnActionEffect struct {
|
|||||||
EnemyDamage int
|
EnemyDamage int
|
||||||
PlayerHeal int
|
PlayerHeal int
|
||||||
EnemySkip bool // control spell: enemy forfeits its attack this round
|
EnemySkip bool // control spell: enemy forfeits its attack this round
|
||||||
|
|
||||||
|
// AllyHeal heals ANOTHER seat instead of the caster — the thing the engine
|
||||||
|
// could not do until §1, and the reason a party cleric was a cleric in name
|
||||||
|
// only. Every heal in the engine wrote to the acting seat: MistyHealProc,
|
||||||
|
// HealItem, PlayerHeal above. Nothing could put a hit point on a friend, so
|
||||||
|
// the class whose entire identity is keeping other people alive could not.
|
||||||
|
// N3 shipped that way and no test noticed, because there was no party golden.
|
||||||
|
//
|
||||||
|
// Zero means no ally heal, which keeps every existing construction of this
|
||||||
|
// struct meaning exactly what it meant before. AllySeat is only read when
|
||||||
|
// AllyHeal > 0, so its zero value is never mistaken for "seat 0".
|
||||||
|
AllyHeal int
|
||||||
|
AllySeat int
|
||||||
// ConcentrationDmg arms a per-round aura tick when a concentration damage
|
// ConcentrationDmg arms a per-round aura tick when a concentration damage
|
||||||
// spell is cast: EnemyDamage is the burst that lands this round, this is
|
// spell is cast: EnemyDamage is the burst that lands this round, this is
|
||||||
// what re-ticks at every round_end after. Zero for one-shot spells; a
|
// what re-ticks at every round_end after. Zero for one-shot spells; a
|
||||||
@@ -560,6 +573,25 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||||
}
|
}
|
||||||
|
// §1 — heal somebody else. The caster's cursor stays where it is; only the
|
||||||
|
// target's HP moves.
|
||||||
|
//
|
||||||
|
// A downed seat is NOT raised. Death in this engine is terminal for the fight
|
||||||
|
// (the close-out marks them, the hospital takes them), and a heal that
|
||||||
|
// resurrected a corpse would quietly rewrite the loss rules every close-out
|
||||||
|
// path depends on. Healing keeps people up; it does not bring them back.
|
||||||
|
if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) {
|
||||||
|
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
|
||||||
|
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
|
||||||
|
before := tgt.playerHP
|
||||||
|
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
|
||||||
|
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
Seat: eff.AllySeat, Desc: eff.Label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
// Arm / replace the concentration aura. A new concentration cast overwrites
|
// Arm / replace the concentration aura. A new concentration cast overwrites
|
||||||
// the old one (5e: one concentration at a time); non-concentration casts
|
// the old one (5e: one concentration at a time); non-concentration casts
|
||||||
// leave any running aura alone.
|
// leave any running aura alone.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -27,9 +29,11 @@ import (
|
|||||||
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
||||||
// a "Phase 11" note — they require the boss turn-based engine.
|
// a "Phase 11" note — they require the boss turn-based engine.
|
||||||
//
|
//
|
||||||
// Cross-player targeting (--target @user) is deliberately deferred. SP2
|
// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human
|
||||||
// supports self-target only. Heals on self work; ally buffs queue with the
|
// on your expedition — see dnd_cast_target.go. Only heals may name somebody else
|
||||||
// caster as the target until SP3 wires up the multi-player resolution.
|
// out of combat: UTILITY resolves on the caster, and everything else queues as a
|
||||||
|
// PendingCast for the *caster's* next fight, where an ally target has nothing to
|
||||||
|
// mean. In-combat targeting is splitCastTarget (combat_cmd.go).
|
||||||
|
|
||||||
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
|
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
|
||||||
// Keep this minimal — the combat layer resolves the actual numbers on fire.
|
// Keep this minimal — the combat layer resolves the actual numbers on fire.
|
||||||
@@ -97,6 +101,13 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
return p.dndCastDrop(ctx, c)
|
return p.dndCastDrop(ctx, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An ally target — `--target @alex`, or a trailing `@alex` — comes off the
|
||||||
|
// string before the spell parser sees it, exactly as in combat. It is
|
||||||
|
// resolved (and refused) further down, once we know the spell is a heal: a
|
||||||
|
// target on anything else is a mistake, and refusing it early would cost us
|
||||||
|
// the "which spell?" half of the error message.
|
||||||
|
args, targetName := splitOutOfCombatTarget(args)
|
||||||
|
|
||||||
// Parse spell name + optional --upcast N.
|
// Parse spell name + optional --upcast N.
|
||||||
tokens := strings.Fields(args)
|
tokens := strings.Fields(args)
|
||||||
upcast := 0
|
upcast := 0
|
||||||
@@ -111,11 +122,6 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
case "--target":
|
|
||||||
// Reserved for SP3 — accept and ignore for now.
|
|
||||||
if i+1 < len(tokens) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
spellTokens = append(spellTokens, tokens[i])
|
spellTokens = append(spellTokens, tokens[i])
|
||||||
}
|
}
|
||||||
@@ -195,6 +201,23 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve an ally target now — BEFORE anything is spent. Out of combat only a
|
||||||
|
// heal can land on somebody else: UTILITY resolves on the caster and
|
||||||
|
// everything else queues as a PendingCast for *this* caster's next fight, so
|
||||||
|
// a target on those would be accepted and then silently dropped.
|
||||||
|
var targetUser id.UserID
|
||||||
|
if targetName != "" {
|
||||||
|
if spell.Effect != EffectSpellHeal {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"%s isn't a healing spell — outside a fight you can only cast those on someone else. Drop the target to cast it on yourself.", spell.Name))
|
||||||
|
}
|
||||||
|
uid, errMsg := resolveCastTargetOnExpedition(ctx.Sender, targetName)
|
||||||
|
if errMsg != "" {
|
||||||
|
return p.SendDM(ctx.Sender, errMsg)
|
||||||
|
}
|
||||||
|
targetUser = uid // empty when they named themselves — self-heal as usual
|
||||||
|
}
|
||||||
|
|
||||||
// Material cost (Revivify, Raise Dead).
|
// Material cost (Revivify, Raise Dead).
|
||||||
if spell.MaterialCost > 0 {
|
if spell.MaterialCost > 0 {
|
||||||
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||||
@@ -220,6 +243,9 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
// debited above; if SaveDnDCharacter fails we refund.
|
// debited above; if SaveDnDCharacter fails we refund.
|
||||||
switch spell.Effect {
|
switch spell.Effect {
|
||||||
case EffectSpellHeal:
|
case EffectSpellHeal:
|
||||||
|
if targetUser != "" {
|
||||||
|
return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel)
|
||||||
|
}
|
||||||
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
||||||
case EffectUtility:
|
case EffectUtility:
|
||||||
return p.resolveUtility(ctx, c, spell, slotLevel)
|
return p.resolveUtility(ctx, c, spell, slotLevel)
|
||||||
@@ -231,7 +257,10 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
|
|
||||||
// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
|
// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
// rollOutOfCombatHeal is what the spell is worth in the caster's hands. It is a
|
||||||
|
// property of the caster (their WIS, their domain), never of the body it lands
|
||||||
|
// on, so an ally heal rolls exactly the same as a self-heal.
|
||||||
|
func rollOutOfCombatHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
|
||||||
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
||||||
if dice == 0 {
|
if dice == 0 {
|
||||||
dice, faces = 1, 8 // safety fallback
|
dice, faces = 1, 8 // safety fallback
|
||||||
@@ -253,6 +282,11 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
|||||||
}
|
}
|
||||||
heal += abilityModifier(c.WIS)
|
heal += abilityModifier(c.WIS)
|
||||||
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
||||||
|
return heal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||||
|
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||||||
|
|
||||||
before := c.HPCurrent
|
before := c.HPCurrent
|
||||||
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
||||||
@@ -272,6 +306,51 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
|||||||
renderSlotsBrief(ctx.Sender)))
|
renderSlotsBrief(ctx.Sender)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveAllyHealOutOfCombat puts the heal on somebody else's sheet. The slot is
|
||||||
|
// already spent, so every failure below refunds it: a heal that lands on nobody
|
||||||
|
// must not cost the caster anything.
|
||||||
|
func (p *AdventurePlugin) resolveAllyHealOutOfCombat(ctx MessageContext, c *DnDCharacter, target id.UserID, spell SpellDefinition, slotLevel int) error {
|
||||||
|
refund := func(msg string) error {
|
||||||
|
if spell.Level > 0 {
|
||||||
|
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||||||
|
before, after, maxHP, err := healPartyMember(target, heal)
|
||||||
|
|
||||||
|
targetName := charName(target)
|
||||||
|
if targetName == "" {
|
||||||
|
targetName = target.Localpart()
|
||||||
|
}
|
||||||
|
casterName := charName(ctx.Sender)
|
||||||
|
if casterName == "" {
|
||||||
|
casterName = ctx.Sender.Localpart()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errHealTargetFull):
|
||||||
|
return refund(fmt.Sprintf("**%s** is already at full health (%d/%d). Slot kept.", targetName, before, maxHP))
|
||||||
|
case errors.Is(err, errHealTargetDown):
|
||||||
|
// The same rule the combat path holds: a heal is not a resurrection.
|
||||||
|
return refund(fmt.Sprintf("**%s** is down — a heal won't bring them back. Slot kept.", targetName))
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return refund(fmt.Sprintf("**%s** hasn't run `!setup` yet, so there's no sheet to heal.", targetName))
|
||||||
|
case err != nil:
|
||||||
|
return refund("Couldn't apply the heal: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = p.SendDM(target, fmt.Sprintf(
|
||||||
|
"🩹 **%s** casts **%s** on you — %d HP back (%d → %d / %d).",
|
||||||
|
casterName, spell.Name, after-before, before, after, maxHP))
|
||||||
|
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"🩹 **%s** on **%s** — restored %d HP (%d → %d / %d). %s",
|
||||||
|
spell.Name, targetName, after-before, before, after, maxHP,
|
||||||
|
renderSlotsBrief(ctx.Sender)))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
|
// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||||
@@ -676,6 +755,7 @@ func renderCastHelp(c *DnDCharacter) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("**Cast a spell**\n\n")
|
b.WriteString("**Cast a spell**\n\n")
|
||||||
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
|
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
|
||||||
|
b.WriteString(" `!cast <heal> @friend` to heal someone on your expedition.\n")
|
||||||
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
|
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
|
||||||
b.WriteString("Run `!spells` for your full list.\n\n")
|
b.WriteString("Run `!spells` for your full list.\n\n")
|
||||||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||||||
|
|||||||
139
internal/plugin/dnd_cast_ally_e2e_test.go
Normal file
139
internal/plugin/dnd_cast_ally_e2e_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The whole `!cast cure wounds @friend` path, out of combat, through the real
|
||||||
|
// handler: parse → class/known/prepared gates → slot debit → the ally's sheet.
|
||||||
|
//
|
||||||
|
// The seams are unit-tested next door; this exists for the one thing they cannot
|
||||||
|
// see — that the slot ledger and the HP write agree about whether the heal
|
||||||
|
// happened. A refund that does not fire is a slot a player paid for nothing, and
|
||||||
|
// a refund that fires twice is a free heal.
|
||||||
|
|
||||||
|
// castingCleric turns an existing sheet into a cleric who knows and has prepared
|
||||||
|
// Cure Wounds, with slots to spend.
|
||||||
|
func castingCleric(t *testing.T, uid id.UserID) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
t.Fatalf("load %s: %v", uid, err)
|
||||||
|
}
|
||||||
|
c.Class = ClassCleric
|
||||||
|
c.Level = 5
|
||||||
|
c.WIS = 16
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := setSpellSlotsForCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := addKnownSpell(uid, "cure_wounds", "class", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := setSpellPrepared(uid, "cure_wounds", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func slotsUsed(t *testing.T, uid id.UserID, level int) int {
|
||||||
|
t.Helper()
|
||||||
|
slots, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return slots[level][1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCharHP(t *testing.T, uid id.UserID, hp int) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
t.Fatalf("load %s: %v", uid, err)
|
||||||
|
}
|
||||||
|
c.HPCurrent = hp
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCastAlly_OutOfCombat_HealsTheFriendAndSpendsOneSlot(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, member := seatedMember(t, "e2eheal")
|
||||||
|
castingCleric(t, leader)
|
||||||
|
setCharHP(t, member, 5) // of 20
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
|
||||||
|
t.Fatalf("cast: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mc, _ := LoadDnDCharacter(member)
|
||||||
|
if mc.HPCurrent <= 5 {
|
||||||
|
t.Fatalf("the friend is still on %d HP; the heal landed on nobody", mc.HPCurrent)
|
||||||
|
}
|
||||||
|
// The caster must NOT have healed themselves — the bug this whole section exists for.
|
||||||
|
if lc, _ := LoadDnDCharacter(leader); lc.HPCurrent != lc.HPMax {
|
||||||
|
t.Fatalf("caster HP moved to %d/%d; the heal landed on the caster", lc.HPCurrent, lc.HPMax)
|
||||||
|
}
|
||||||
|
if used := slotsUsed(t, leader, 1); used != 1 {
|
||||||
|
t.Fatalf("level-1 slots used = %d, want exactly 1", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naming a full-HP ally must cost nothing: the slot is debited before the target
|
||||||
|
// is touched, so this is the refund path firing for real.
|
||||||
|
func TestCastAlly_OutOfCombat_FullHPAllyRefundsTheSlot(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, member := seatedMember(t, "e2efull")
|
||||||
|
castingCleric(t, leader)
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
|
||||||
|
t.Fatalf("cast: %v", err)
|
||||||
|
}
|
||||||
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||||
|
t.Fatalf("level-1 slots used = %d after healing a full-HP ally; the slot was not refunded", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A target nobody can reach is refused before anything is spent.
|
||||||
|
func TestCastAlly_OutOfCombat_StrangerCostsNothing(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, _ := seatedMember(t, "e2estranger")
|
||||||
|
castingCleric(t, leader)
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @nobody"); err != nil {
|
||||||
|
t.Fatalf("cast: %v", err)
|
||||||
|
}
|
||||||
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||||
|
t.Fatalf("level-1 slots used = %d after naming a stranger; nothing should have been spent", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A target on a spell that cannot use one is refused, rather than quietly
|
||||||
|
// dropping the target and firing the spell at the caster — which is what the old
|
||||||
|
// "accept and ignore" parse did.
|
||||||
|
func TestCastAlly_OutOfCombat_TargetOnANonHealIsRefused(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, member := seatedMember(t, "e2enonheal")
|
||||||
|
castingCleric(t, leader)
|
||||||
|
if err := addKnownSpell(leader, "guiding_bolt", "class", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "guiding bolt @"+member.Localpart()); err != nil {
|
||||||
|
t.Fatalf("cast: %v", err)
|
||||||
|
}
|
||||||
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||||
|
t.Fatalf("level-1 slots used = %d; a targeted non-heal must be refused before it is paid for", used)
|
||||||
|
}
|
||||||
|
if lc, _ := LoadDnDCharacter(leader); lc.PendingCast != "" {
|
||||||
|
t.Fatalf("guiding bolt queued as %q; the target should have refused the cast outright", lc.PendingCast)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
internal/plugin/dnd_cast_target.go
Normal file
156
internal/plugin/dnd_cast_target.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Out-of-combat ally targeting for `!cast` — the other half of §1.
|
||||||
|
//
|
||||||
|
// splitCastTarget (combat_cmd.go) resolves a target against the people *in the
|
||||||
|
// fight*. Out of combat there is no fight, so the equivalent set is the people
|
||||||
|
// on your expedition: the party is who you are standing next to. Healing a
|
||||||
|
// stranger across the world is not a thing a cleric at camp can do, and scoping
|
||||||
|
// it to the party keeps the two `!cast` paths answering the same question.
|
||||||
|
//
|
||||||
|
// Only heals may name somebody else. Everything else `!cast` can do out of
|
||||||
|
// combat either resolves on the caster (UTILITY) or queues as a PendingCast for
|
||||||
|
// **the caster's** next fight — an ally target on those would be silently
|
||||||
|
// dropped, which is how `--target` came to be swallowed in the first place.
|
||||||
|
|
||||||
|
// splitOutOfCombatTarget peels an ally target off a `!cast` argument, using the
|
||||||
|
// same two spellings the combat path accepts: the `--target @alex` flag, and a
|
||||||
|
// plain trailing `@alex`. The bare form requires the `@` here (unlike in combat,
|
||||||
|
// where the roster disambiguates) so a trailing spell word is never mistaken for
|
||||||
|
// a name.
|
||||||
|
//
|
||||||
|
// Returns (remainingArgs, name). name is empty when no target was named.
|
||||||
|
func splitOutOfCombatTarget(args string) (string, string) {
|
||||||
|
fields := strings.Fields(args)
|
||||||
|
for i := 0; i < len(fields); i++ {
|
||||||
|
if !strings.EqualFold(fields[i], "--target") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(fields) {
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(fields[i+1], "@")
|
||||||
|
fields = append(fields[:i], fields[i+2:]...)
|
||||||
|
return strings.Join(fields, " "), name
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
if last := fields[len(fields)-1]; strings.HasPrefix(last, "@") {
|
||||||
|
if name := strings.TrimPrefix(last, "@"); name != "" {
|
||||||
|
return strings.Join(fields[:len(fields)-1], " "), name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCastTargetOnExpedition maps a named target to a human on the caster's
|
||||||
|
// active expedition. errMsg is non-empty and player-facing on any failure; a
|
||||||
|
// caster who names somebody must never fall through to healing themselves,
|
||||||
|
// because that quietly burns the slot on the wrong body.
|
||||||
|
func resolveCastTargetOnExpedition(caster id.UserID, name string) (id.UserID, string) {
|
||||||
|
exp, _, err := activeExpeditionFor(caster)
|
||||||
|
if err != nil {
|
||||||
|
return "", "Couldn't look up your expedition."
|
||||||
|
}
|
||||||
|
if exp == nil {
|
||||||
|
return "", "You're not travelling with anyone. `!cast` on yourself, or join a party first."
|
||||||
|
}
|
||||||
|
seats, err := partyHumans(exp.ID, exp.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "Couldn't look up your party."
|
||||||
|
}
|
||||||
|
for _, s := range seats {
|
||||||
|
uid := string(s.UserID)
|
||||||
|
if strings.EqualFold(uid, name) || strings.EqualFold(id.UserID(uid).Localpart(), name) {
|
||||||
|
if s.UserID == caster {
|
||||||
|
// Naming yourself is just casting it on yourself.
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return s.UserID, ""
|
||||||
|
}
|
||||||
|
// Character name, which is what players actually call each other.
|
||||||
|
if n := charName(s.UserID); n != "" && strings.EqualFold(n, name) {
|
||||||
|
if s.UserID == caster {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return s.UserID, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(name, companionDisplayName) {
|
||||||
|
return "", companionDisplayName + " patches himself up at camp. Save the slot."
|
||||||
|
}
|
||||||
|
return "", fmt.Sprintf("**%s** isn't on your expedition. You can only heal someone you're travelling with.", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errHealTargetFull / errHealTargetDown are the two ways an ally heal declines
|
||||||
|
// to spend the slot. Both are recoverable: the caller refunds.
|
||||||
|
var (
|
||||||
|
errHealTargetFull = errors.New("target already at full HP")
|
||||||
|
errHealTargetDown = errors.New("target is down")
|
||||||
|
)
|
||||||
|
|
||||||
|
// healPartyMember applies a heal to somebody else's sheet.
|
||||||
|
//
|
||||||
|
// It does NOT take the target's advUserLock. Gifting sets the precedent
|
||||||
|
// (adventure_gifting.go): mutate the other player's row with one guarded
|
||||||
|
// statement rather than a read-modify-write under two locks. Two clerics healing
|
||||||
|
// each other at the same instant would otherwise take those locks in opposite
|
||||||
|
// orders and deadlock the pair of them.
|
||||||
|
//
|
||||||
|
// The clamp lives in SQL for the same reason — read, add, cap and write are one
|
||||||
|
// statement, so a heal landing between a concurrent heal's read and write cannot
|
||||||
|
// push anybody past their maximum.
|
||||||
|
func healPartyMember(target id.UserID, amount int) (before, after, maxHP int, err error) {
|
||||||
|
tx, err := db.Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
err = tx.QueryRow(
|
||||||
|
`SELECT hp_current, hp_max FROM dnd_character WHERE user_id = ?`,
|
||||||
|
string(target)).Scan(&before, &maxHP)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, 0, 0, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case before <= 0:
|
||||||
|
// A heal is not a resurrection — the same rule the combat path holds.
|
||||||
|
return before, before, maxHP, errHealTargetDown
|
||||||
|
case before >= maxHP:
|
||||||
|
return before, before, maxHP, errHealTargetFull
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec(
|
||||||
|
`UPDATE dnd_character
|
||||||
|
SET hp_current = MIN(hp_max, hp_current + ?),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND hp_current > 0`,
|
||||||
|
amount, string(target)); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
if err = tx.QueryRow(
|
||||||
|
`SELECT hp_current FROM dnd_character WHERE user_id = ?`,
|
||||||
|
string(target)).Scan(&after); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
return before, after, maxHP, nil
|
||||||
|
}
|
||||||
102
internal/plugin/dnd_cast_target_test.go
Normal file
102
internal/plugin/dnd_cast_target_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The out-of-combat half of §1. `--target` was parsed and thrown away here since
|
||||||
|
// SP2 ("reserved for SP3, accept and ignore"), so a party cleric standing over a
|
||||||
|
// bleeding friend between fights could do precisely nothing for them.
|
||||||
|
|
||||||
|
func TestSplitOutOfCombatTarget(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, args, wantRest, wantTarget string
|
||||||
|
}{
|
||||||
|
{"flag", "cure wounds --target @alex", "cure wounds", "alex"},
|
||||||
|
{"flag mid-string", "cure wounds --target @alex --upcast 3", "cure wounds --upcast 3", "alex"},
|
||||||
|
{"flag without the @", "cure wounds --target alex", "cure wounds", "alex"},
|
||||||
|
{"trailing mention", "cure wounds @alex", "cure wounds", "alex"},
|
||||||
|
{"no target", "cure wounds", "cure wounds", ""},
|
||||||
|
// A trailing bare word is a spell word, not a name — the `@` is what makes
|
||||||
|
// it a target out of combat. Getting this wrong eats half of "cure wounds".
|
||||||
|
{"bare trailing word is not a target", "healing word", "healing word", ""},
|
||||||
|
{"upcast digit is not a target", "cure wounds --upcast 3", "cure wounds --upcast 3", ""},
|
||||||
|
{"dangling flag", "cure wounds --target", "cure wounds --target", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rest, target := splitOutOfCombatTarget(tc.args)
|
||||||
|
if rest != tc.wantRest || target != tc.wantTarget {
|
||||||
|
t.Fatalf("splitOutOfCombatTarget(%q) = (%q, %q), want (%q, %q)",
|
||||||
|
tc.args, rest, target, tc.wantRest, tc.wantTarget)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The target set is the expedition, not the world: you can heal the person you
|
||||||
|
// are travelling with, and nobody else.
|
||||||
|
func TestResolveCastTarget_OnlyThePartyIsReachable(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
leader, member := seatedMember(t, "casttarget")
|
||||||
|
|
||||||
|
uid, errMsg := resolveCastTargetOnExpedition(leader, member.Localpart())
|
||||||
|
if errMsg != "" || uid != member {
|
||||||
|
t.Fatalf("leader → member = (%q, %q); want the member, no error", uid, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody with a sheet, but not on this expedition.
|
||||||
|
stranger := id.UserID("@stranger-casttarget:example.org")
|
||||||
|
zoneCmdTestCharacter(t, stranger, 1)
|
||||||
|
if _, errMsg := resolveCastTargetOnExpedition(leader, stranger.Localpart()); errMsg == "" {
|
||||||
|
t.Fatal("healed a stranger across the world; only the party is reachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naming yourself is just casting it on yourself: no target, no error. A
|
||||||
|
// refusal here would make `!cast cure wounds @me` cost a slot for nothing.
|
||||||
|
if uid, errMsg := resolveCastTargetOnExpedition(leader, leader.Localpart()); uid != "" || errMsg != "" {
|
||||||
|
t.Fatalf("self-target = (%q, %q); want the self-heal path, silently", uid, errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealPartyMember_ClampsAtMaxAndWillNotRaiseTheDead(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
target := id.UserID("@heal-target:example.org")
|
||||||
|
zoneCmdTestCharacter(t, target, 1) // HPMax 20
|
||||||
|
|
||||||
|
setHP := func(hp int) {
|
||||||
|
c, err := LoadDnDCharacter(target)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
c.HPCurrent = hp
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A heal bigger than the wound tops out at max rather than overflowing.
|
||||||
|
setHP(15)
|
||||||
|
before, after, maxHP, err := healPartyMember(target, 100)
|
||||||
|
if err != nil || before != 15 || after != 20 || maxHP != 20 {
|
||||||
|
t.Fatalf("overheal = (%d → %d / %d, %v); want clamped to 20", before, after, maxHP, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already full: decline so the caller can refund. Spending a slot to heal
|
||||||
|
// zero HP is the kind of thing players never forgive.
|
||||||
|
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetFull) {
|
||||||
|
t.Fatalf("healing a full-HP ally = %v; want errHealTargetFull", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down: a heal is not a resurrection. Same rule the combat path holds.
|
||||||
|
setHP(0)
|
||||||
|
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetDown) {
|
||||||
|
t.Fatalf("healing a downed ally = %v; want errHealTargetDown", err)
|
||||||
|
}
|
||||||
|
if c, _ := LoadDnDCharacter(target); c == nil || c.HPCurrent != 0 {
|
||||||
|
t.Fatal("the downed ally was raised by a heal")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -575,4 +576,85 @@ func markAdventureDead(userID id.UserID, source, location string) {
|
|||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
|
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
|
||||||
}
|
}
|
||||||
|
emitDeathNews(userID, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitDeathNews files a BULLETIN 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.
|
||||||
|
//
|
||||||
|
// Bulletin, not priority: the room watched the character go down in TwinBee's
|
||||||
|
// narration, and a priority fact is what makes Pete post a live beat — he'd be
|
||||||
|
// breaking the news to the people who were there. The site row and the digest
|
||||||
|
// still carry it.
|
||||||
|
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: "bulletin",
|
||||||
|
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 {
|
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
|
||||||
_ = refreshAllResources(uid)
|
_ = refreshAllResources(uid)
|
||||||
_ = refreshSpellSlots(uid)
|
_ = refreshSpellSlots(uid)
|
||||||
|
// The companion sleeps at the same fire. His slots and his wounds are not
|
||||||
|
// dnd_spell_slots / dnd_character rows — he has none — so nothing above
|
||||||
|
// reaches him, and without these two his pool and his body would only ever
|
||||||
|
// go down. Camp is where a caster gets their slots back and a body gets
|
||||||
|
// patched up, and he is at the camp.
|
||||||
|
_ = refreshCompanionSlots(e.ID)
|
||||||
|
_ = refreshCompanionHP(e.ID)
|
||||||
_ = ReplenishHarvestNodes(e)
|
_ = ReplenishHarvestNodes(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
return p.expeditionCmdParty(ctx)
|
return p.expeditionCmdParty(ctx)
|
||||||
case "leave":
|
case "leave":
|
||||||
return p.expeditionCmdLeave(ctx)
|
return p.expeditionCmdLeave(ctx)
|
||||||
|
case "hire":
|
||||||
|
return p.expeditionCmdHire(ctx, rest)
|
||||||
|
case "dismiss":
|
||||||
|
return p.expeditionCmdDismiss(ctx)
|
||||||
case "extract":
|
case "extract":
|
||||||
return p.handleExtractCmd(ctx, "")
|
return p.handleExtractCmd(ctx, "")
|
||||||
case "resume":
|
case "resume":
|
||||||
@@ -122,7 +126,9 @@ func expeditionHelpText() string {
|
|||||||
b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
|
b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
|
||||||
b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
|
b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
|
||||||
b.WriteString("`!expedition party` — who's with you\n")
|
b.WriteString("`!expedition party` — who's with you\n")
|
||||||
b.WriteString("`!expedition leave` — turn back for town (members only)\n\n")
|
b.WriteString("`!expedition leave` — turn back for town (members only)\n")
|
||||||
|
b.WriteString("`!expedition hire [class]` — pay Pete to fill the role you're missing\n")
|
||||||
|
b.WriteString("`!expedition dismiss` — send Pete home\n\n")
|
||||||
b.WriteString("**Mid-expedition:**\n")
|
b.WriteString("**Mid-expedition:**\n")
|
||||||
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
|
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
|
||||||
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
|
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
|
||||||
@@ -377,44 +383,10 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
}
|
}
|
||||||
|
|
||||||
zone := zoneForCaps
|
zone := zoneForCaps
|
||||||
// Holiday perk: a complimentary standard pack is added to the supplies
|
_, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting")
|
||||||
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
|
||||||
// on purpose — it's a freebie on top of whatever the player bought.
|
|
||||||
suppliesPurchase := purchase
|
|
||||||
if isHol, _ := isHolidayToday(); isHol {
|
|
||||||
suppliesPurchase.StandardPacks++
|
|
||||||
}
|
|
||||||
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
|
||||||
suppliesPurchase.StandardPacks += pk
|
|
||||||
}
|
|
||||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
|
||||||
|
|
||||||
// Debit coins; bail on debit failure (race / cap).
|
|
||||||
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(zoneID)) {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
|
||||||
}
|
|
||||||
|
|
||||||
exp, err := startExpedition(ctx.Sender, zoneID, "", supplies)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Refund on persistence failure.
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund")
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't start expedition: "+err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
|
|
||||||
// harvest depends on this; the existing zone-run !advance/!retreat
|
|
||||||
// commands now operate on this run while the expedition is active.
|
|
||||||
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
|
||||||
// Refund and tear the expedition row back down — without a
|
|
||||||
// linked run, harvest and rooms can't function.
|
|
||||||
_ = abandonExpedition(ctx.Sender)
|
|
||||||
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund (run-spawn failed)")
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't outfit the first region: "+err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the start with prewritten flavor.
|
|
||||||
startLine := flavor.Pick(flavor.ExpeditionStart)
|
|
||||||
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
|
||||||
markActedToday(ctx.Sender)
|
markActedToday(ctx.Sender)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -436,6 +408,65 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// beginExpedition performs the non-interactive half of starting an expedition:
|
||||||
|
// supply freebies, the coin debit, persistence, the starting region's run, and
|
||||||
|
// the opening log entry. It refunds and tears down on every failure path, so a
|
||||||
|
// caller holding an error owes the player nothing.
|
||||||
|
//
|
||||||
|
// Callers own the balance check, the eligibility guards, and the player-facing
|
||||||
|
// messaging. This is the shared transaction under `!expedition start` and the
|
||||||
|
// boredom ticker (gogobee_boredom_plan.md §4); the returned errors are written
|
||||||
|
// as complete player-facing sentences so both callers can surface them as-is.
|
||||||
|
//
|
||||||
|
// It deliberately does NOT call markActedToday — an expedition the player did
|
||||||
|
// not ask for must not spend their daily action or count as them showing up.
|
||||||
|
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
|
||||||
|
if p.euro == nil {
|
||||||
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
|
||||||
|
}
|
||||||
|
cost := float64(purchase.Cost())
|
||||||
|
|
||||||
|
// Holiday perk: a complimentary standard pack is added to the supplies
|
||||||
|
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
||||||
|
// on purpose — it's a freebie on top of whatever the player bought.
|
||||||
|
suppliesPurchase := purchase
|
||||||
|
if isHol, _ := isHolidayToday(); isHol {
|
||||||
|
suppliesPurchase.StandardPacks++
|
||||||
|
}
|
||||||
|
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
||||||
|
suppliesPurchase.StandardPacks += pk
|
||||||
|
}
|
||||||
|
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||||
|
|
||||||
|
// Debit coins; bail on debit failure (race / cap).
|
||||||
|
if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) {
|
||||||
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
|
||||||
|
}
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, zone.ID, "", supplies)
|
||||||
|
if err != nil {
|
||||||
|
// Refund on persistence failure.
|
||||||
|
p.euro.Credit(uid, cost, "expedition outfitting refund")
|
||||||
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
|
||||||
|
// harvest depends on this; the existing zone-run !advance/!retreat
|
||||||
|
// commands now operate on this run while the expedition is active.
|
||||||
|
if _, err := ensureRegionRun(exp, charLevel); err != nil {
|
||||||
|
// Refund and tear the expedition row back down — without a
|
||||||
|
// linked run, harvest and rooms can't function.
|
||||||
|
_ = abandonExpedition(uid)
|
||||||
|
p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)")
|
||||||
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the start with prewritten flavor.
|
||||||
|
startLine := flavor.Pick(flavor.ExpeditionStart)
|
||||||
|
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
||||||
|
return exp, supplies, startLine, nil
|
||||||
|
}
|
||||||
|
|
||||||
// raidContentWarning returns a TwinBee-voiced heads-up for zones whose
|
// raidContentWarning returns a TwinBee-voiced heads-up for zones whose
|
||||||
// boss is tuned for a party rather than a solo adventurer. T5 zones
|
// boss is tuned for a party rather than a solo adventurer. T5 zones
|
||||||
// (Dragon's Lair / Abyss Portal) have boss HP / damage curves that the
|
// (Dragon's Lair / Abyss Portal) have boss HP / damage curves that the
|
||||||
|
|||||||
@@ -95,21 +95,40 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
|
|||||||
return e, tax, nil
|
return e, tax, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reasons a run can end badly. They were bare strings at four call sites;
|
||||||
|
// they are constants now because the news seam has to tell them apart — a
|
||||||
|
// retreat is a story and a death is a different story, and an idle reap is
|
||||||
|
// neither.
|
||||||
|
const (
|
||||||
|
lossCombatDeath = "combat death"
|
||||||
|
lossCombatRetreat = "combat retreat" // solo: ran out the phase clock and withdrew
|
||||||
|
lossCombatFlee = "combat flee" // party: the turn engine broke off
|
||||||
|
lossIdleTimeout = "run idle-timeout (§4.3 stale-run reap)"
|
||||||
|
)
|
||||||
|
|
||||||
// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based
|
// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based
|
||||||
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
||||||
// death) into the forced-extract flow. Those sites already abandon the
|
// death) into the forced-extract flow. Those sites already abandon the
|
||||||
// zone run, but without flipping the wrapping expedition the ambient
|
// zone run, but without flipping the wrapping expedition the ambient
|
||||||
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
||||||
// when there is no active expedition for this user.
|
// when there is no active expedition for this user.
|
||||||
|
//
|
||||||
|
// It is also the one chokepoint every bad ending passes through, which makes it
|
||||||
|
// where the retreat dispatch is filed. Read the expedition BEFORE the extract:
|
||||||
|
// forcedExtractExpedition stamps it 'abandoned' and zeroes the live fields, so
|
||||||
|
// afterwards there is no day count left to report.
|
||||||
func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
|
func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
|
||||||
exp, err := getActiveExpedition(userID)
|
exp, err := getActiveExpedition(userID)
|
||||||
if err != nil || exp == nil {
|
if err != nil || exp == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
day, zoneID := exp.CurrentDay, exp.ZoneID
|
||||||
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
||||||
slog.Warn("expedition: force-extract on run loss",
|
slog.Warn("expedition: force-extract on run loss",
|
||||||
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
emitRetreatNews(userID, reason, zoneID, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
||||||
@@ -177,6 +196,9 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
|
|||||||
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
|
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
|
||||||
lines = append(lines, sl)
|
lines = append(lines, sl)
|
||||||
}
|
}
|
||||||
|
// News: boss down means the zone is cleared. Realm-first → PRIORITY,
|
||||||
|
// repeat → BULLETIN. No-op unless the Pete seam is enabled.
|
||||||
|
emitZoneClearNews(userID, exp)
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -290,6 +291,22 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
|
|||||||
// Idempotent — !setup confirm after a respec wipe will repopulate.
|
// Idempotent — !setup confirm after a respec wipe will repopulate.
|
||||||
_ = ensureSpellsForCharacter(c)
|
_ = ensureSpellsForCharacter(c)
|
||||||
|
|
||||||
|
// A new character walked through the gates — file a welcome dispatch to
|
||||||
|
// Pete. GUID keyed on the user alone so a later respec/re-confirm can't
|
||||||
|
// re-announce the same arrival. No-op unless the seam is enabled.
|
||||||
|
if name := charName(ctx.Sender); name != "" {
|
||||||
|
ts := nowUnix()
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: "arrival:" + eventToken(ctx.Sender, "arrival"),
|
||||||
|
EventType: "arrival",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: name,
|
||||||
|
ClassRace: classRaceLabel(c),
|
||||||
|
Level: c.Level,
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, ctx.Sender, "")
|
||||||
|
}
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, renderSetupComplete(c))
|
return p.SendDM(ctx.Sender, renderSetupComplete(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1207,9 +1207,9 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
// seat off HP, which for a solo walker is the same `!TimedOut` rule.
|
// seat off HP, which for a solo walker is the same `!TimedOut` rule.
|
||||||
closeOutZoneLoss(pres, seated, zone, "zone")
|
closeOutZoneLoss(pres, seated, zone, "zone")
|
||||||
if !result.TimedOut {
|
if !result.TimedOut {
|
||||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
forceExtractExpeditionForRunLoss(userID, lossCombatDeath)
|
||||||
} else {
|
} else {
|
||||||
forceExtractExpeditionForRunLoss(userID, "combat retreat")
|
forceExtractExpeditionForRunLoss(userID, lossCombatRetreat)
|
||||||
}
|
}
|
||||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
|
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
|
||||||
ob.WriteString(line)
|
ob.WriteString(line)
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
|||||||
// but only when this run is the active expedition's current run so
|
// but only when this run is the active expedition's current run so
|
||||||
// a standalone (non-expedition) stale run still reaps cleanly.
|
// a standalone (non-expedition) stale run still reaps cleanly.
|
||||||
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
|
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
|
||||||
forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)")
|
forceExtractExpeditionForRunLoss(userID, lossIdleTimeout)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -413,6 +416,131 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
|
|||||||
return balance
|
return balance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Idempotent money moves
|
||||||
|
//
|
||||||
|
// Every caller of Credit/Debit above is a Matrix message, which arrives exactly
|
||||||
|
// once. Money that moves over a retrying wire — the Pete games escrow poll loop
|
||||||
|
// — has no such guarantee: a claim that succeeds but whose ack is lost gets
|
||||||
|
// retried, and the player pays twice. CreditIdem/DebitIdem take an externalID
|
||||||
|
// (the escrow GUID), do the balance mutation and the transaction log in one
|
||||||
|
// tx, and lean on idx_euro_tx_external so a replay is a no-op rather than a
|
||||||
|
// second debit.
|
||||||
|
//
|
||||||
|
// Anything web-initiated goes through these. Nothing else should.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DebitIdem subtracts euros exactly once for a given externalID. Returns ok=false
|
||||||
|
// only when the debit would breach the debt limit; a replay of an already-applied
|
||||||
|
// externalID returns ok=true without moving money again. balanceAfter is the
|
||||||
|
// balance as of the decision, and is meaningless when ok is false.
|
||||||
|
func (p *EuroPlugin) DebitIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) {
|
||||||
|
if amount <= 0 {
|
||||||
|
return false, 0, fmt.Errorf("euro: DebitIdem non-positive amount %v", amount)
|
||||||
|
}
|
||||||
|
if externalID == "" {
|
||||||
|
return false, 0, fmt.Errorf("euro: DebitIdem requires an external id")
|
||||||
|
}
|
||||||
|
debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000)
|
||||||
|
return p.applyIdem(userID, -amount, reason, externalID, -debtLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreditIdem adds euros exactly once for a given externalID. A replay is a no-op
|
||||||
|
// that still reports ok.
|
||||||
|
func (p *EuroPlugin) CreditIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) {
|
||||||
|
if amount <= 0 {
|
||||||
|
return false, 0, fmt.Errorf("euro: CreditIdem non-positive amount %v", amount)
|
||||||
|
}
|
||||||
|
if externalID == "" {
|
||||||
|
return false, 0, fmt.Errorf("euro: CreditIdem requires an external id")
|
||||||
|
}
|
||||||
|
// A credit has no floor to breach — math.Inf would do, but the balance can
|
||||||
|
// never land below the current one, so any sentinel below it works.
|
||||||
|
return p.applyIdem(userID, amount, reason, externalID, math.Inf(-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyIdem is the shared body: signed delta, applied once, never letting the
|
||||||
|
// balance fall below floor. Balance mutation and transaction log commit together
|
||||||
|
// or not at all.
|
||||||
|
func (p *EuroPlugin) applyIdem(userID id.UserID, delta float64, reason, externalID string, floor float64) (bool, float64, error) {
|
||||||
|
// Outside the tx: db is capped at a single connection, so a nested Exec on
|
||||||
|
// the pool would deadlock against our own open transaction.
|
||||||
|
p.ensureBalance(userID)
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, fmt.Errorf("euro: begin: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after a successful Commit
|
||||||
|
|
||||||
|
// Already applied? Report the current balance and move on.
|
||||||
|
var prior int
|
||||||
|
switch err := tx.QueryRow(
|
||||||
|
`SELECT 1 FROM euro_transactions WHERE external_id = ?`, externalID,
|
||||||
|
).Scan(&prior); {
|
||||||
|
case err == nil:
|
||||||
|
bal, err := txBalance(tx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
slog.Info("euro: idempotent replay ignored", "user", userID, "external_id", externalID, "reason", reason)
|
||||||
|
return true, bal, nil
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
// Not applied yet — carry on.
|
||||||
|
default:
|
||||||
|
return false, 0, fmt.Errorf("euro: idem lookup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE euro_balances SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND (balance + ?) >= ?`,
|
||||||
|
delta, string(userID), delta, floor,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, fmt.Errorf("euro: idem balance update: %w", err)
|
||||||
|
}
|
||||||
|
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||||
|
return false, 0, nil // would breach the floor
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO euro_transactions (user_id, amount, reason, external_id) VALUES (?, ?, ?, ?)`,
|
||||||
|
string(userID), delta, reason, externalID,
|
||||||
|
); err != nil {
|
||||||
|
// A racing caller won with the same externalID. The unique index is the
|
||||||
|
// backstop the SELECT above can't be: roll back our half-applied delta
|
||||||
|
// and report theirs.
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
|
if rbErr := tx.Rollback(); rbErr != nil {
|
||||||
|
return false, 0, fmt.Errorf("euro: rollback after idem race: %w", rbErr)
|
||||||
|
}
|
||||||
|
slog.Info("euro: idempotent race lost, delta rolled back", "user", userID, "external_id", externalID)
|
||||||
|
return true, p.GetBalance(userID), nil
|
||||||
|
}
|
||||||
|
return false, 0, fmt.Errorf("euro: idem tx log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bal, err := txBalance(tx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return false, 0, fmt.Errorf("euro: commit: %w", err)
|
||||||
|
}
|
||||||
|
return true, bal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func txBalance(tx *sql.Tx, userID id.UserID) (float64, error) {
|
||||||
|
var bal float64
|
||||||
|
if err := tx.QueryRow(
|
||||||
|
`SELECT balance FROM euro_balances WHERE user_id = ?`, string(userID),
|
||||||
|
).Scan(&bal); err != nil {
|
||||||
|
return 0, fmt.Errorf("euro: read balance: %w", err)
|
||||||
|
}
|
||||||
|
return bal, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
|
func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
|
||||||
db.Exec("euro: log transaction",
|
db.Exec("euro: log transaction",
|
||||||
"INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)",
|
"INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)",
|
||||||
|
|||||||
174
internal/plugin/euro_idem_test.go
Normal file
174
internal/plugin/euro_idem_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEuroTestDB(t *testing.T) *EuroPlugin {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
return NewEuroPlugin(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedBalance(t *testing.T, p *EuroPlugin, user id.UserID, amount float64) {
|
||||||
|
t.Helper()
|
||||||
|
p.ensureBalance(user)
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE euro_balances SET balance = ? WHERE user_id = ?`, amount, string(user),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebitIdem_ReplaySameGUIDDebitsOnce(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@replay:test.invalid")
|
||||||
|
seedBalance(t, p, user, 500)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ok, bal, err := p.DebitIdem(user, 100, "games_buyin", "guid-abc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("attempt %d: debit rejected", i)
|
||||||
|
}
|
||||||
|
if bal != 400 {
|
||||||
|
t.Fatalf("attempt %d: balance after = %v, want 400", i, bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.GetBalance(user); got != 400 {
|
||||||
|
t.Fatalf("final balance = %v, want 400 (replay debited more than once)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM euro_transactions WHERE external_id = 'guid-abc'`,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("logged %d transactions for one guid, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreditIdem_ReplaySameGUIDCreditsOnce(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@cashout:test.invalid")
|
||||||
|
seedBalance(t, p, user, 0)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ok, bal, err := p.CreditIdem(user, 250, "games_cashout", "guid-xyz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if !ok || bal != 250 {
|
||||||
|
t.Fatalf("attempt %d: ok=%v balance=%v, want true/250", i, ok, bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.GetBalance(user); got != 250 {
|
||||||
|
t.Fatalf("final balance = %v, want 250", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebitIdem_DistinctGUIDsBothApply(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@distinct:test.invalid")
|
||||||
|
seedBalance(t, p, user, 500)
|
||||||
|
|
||||||
|
for _, guid := range []string{"guid-1", "guid-2"} {
|
||||||
|
if ok, _, err := p.DebitIdem(user, 100, "games_buyin", guid); err != nil || !ok {
|
||||||
|
t.Fatalf("%s: ok=%v err=%v", guid, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := p.GetBalance(user); got != 300 {
|
||||||
|
t.Fatalf("balance = %v, want 300", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebitIdem_RespectsDebtLimitAndLogsNothing(t *testing.T) {
|
||||||
|
t.Setenv("BLACKJACK_DEBT_LIMIT", "1000")
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@broke:test.invalid")
|
||||||
|
seedBalance(t, p, user, 0)
|
||||||
|
|
||||||
|
ok, _, err := p.DebitIdem(user, 1500, "games_buyin", "guid-broke")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("debit past the debt limit was accepted")
|
||||||
|
}
|
||||||
|
if got := p.GetBalance(user); got != 0 {
|
||||||
|
t.Fatalf("balance = %v, want 0 — a rejected debit moved money", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rejection must leave no trace, so the same guid can be retried later
|
||||||
|
// (e.g. once the player has earned their way back above the limit).
|
||||||
|
var n int
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM euro_transactions WHERE external_id = 'guid-broke'`,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("rejected debit logged %d transactions, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SELECT-then-UPDATE in applyIdem is not by itself a guard against two
|
||||||
|
// concurrent claims of the same guid; the unique index is. Hammer it.
|
||||||
|
func TestDebitIdem_ConcurrentSameGUIDDebitsOnce(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@race:test.invalid")
|
||||||
|
seedBalance(t, p, user, 1000)
|
||||||
|
|
||||||
|
const racers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make([]error, racers)
|
||||||
|
for i := 0; i < racers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _, errs[i] = p.DebitIdem(user, 100, "games_buyin", "guid-race")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("racer %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := p.GetBalance(user); got != 900 {
|
||||||
|
t.Fatalf("balance = %v, want 900 — %d concurrent claims of one guid double-debited", got, racers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdem_RejectsEmptyExternalID(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@noguid:test.invalid")
|
||||||
|
seedBalance(t, p, user, 500)
|
||||||
|
|
||||||
|
if _, _, err := p.DebitIdem(user, 10, "games_buyin", ""); err == nil {
|
||||||
|
t.Fatal("DebitIdem accepted an empty external id")
|
||||||
|
}
|
||||||
|
if _, _, err := p.CreditIdem(user, 10, "games_cashout", ""); err == nil {
|
||||||
|
t.Fatal("CreditIdem accepted an empty external id")
|
||||||
|
}
|
||||||
|
if got := p.GetBalance(user); got != 500 {
|
||||||
|
t.Fatalf("balance = %v, want 500", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
427
internal/plugin/exercise_boredom_prod_test.go
Normal file
427
internal/plugin/exercise_boredom_prod_test.go
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
//go:build prodexercise
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
// Headless end-to-end exercise of the bored-adventurer path against a COPY of
|
||||||
|
// the prod DB. This covers the one seam the unit tests can't reach: the
|
||||||
|
// tryBoredomStart → beginExpedition → autopilot chain, which needs a live
|
||||||
|
// EuroPlugin and a real character with real gear.
|
||||||
|
//
|
||||||
|
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||||
|
// go test -tags prodexercise -run TestExerciseBoredomProd -v ./internal/plugin/
|
||||||
|
//
|
||||||
|
// As with TestExerciseNewFeaturesProd, the DB copy is re-copied into t.TempDir()
|
||||||
|
// before opening, so even the copy is left pristine and nothing can reach prod.
|
||||||
|
//
|
||||||
|
// What it has to prove:
|
||||||
|
// 1. The idle clock picks out exactly the idle players — a player who acted an
|
||||||
|
// hour ago must NOT be swept up. (playerIsIdle fails open, so a regression
|
||||||
|
// here marches the whole server into a dungeon at once.)
|
||||||
|
// 2. A bored run actually starts against a real character: real zone, lean
|
||||||
|
// supplies, coins debited.
|
||||||
|
// 3. It buys and equips nothing. That's the whole mechanic.
|
||||||
|
// 4. The expedition is flagged boredom=1, so the idle reaper won't let it
|
||||||
|
// shield an absent player's streak.
|
||||||
|
// 5. It re-entrantly refuses: a second tick inside the cooldown does nothing.
|
||||||
|
// 6. The autopilot will actually walk it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExerciseBoredomProd(t *testing.T) {
|
||||||
|
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||||||
|
if srcDir == "" {
|
||||||
|
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||||||
|
t.Skipf("no gogobee.db in %s", srcDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||||||
|
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(tmp); err != nil {
|
||||||
|
t.Fatalf("db.Init: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
|
||||||
|
simOmenDisabled = true
|
||||||
|
|
||||||
|
euro := NewEuroPlugin(nil)
|
||||||
|
xp := NewXPPlugin(nil)
|
||||||
|
p := NewAdventurePlugin(nil, euro, xp)
|
||||||
|
sink := &captureSink{}
|
||||||
|
p.Sink = sink
|
||||||
|
mark := 0
|
||||||
|
drain := func() {
|
||||||
|
for _, m := range sink.msgs[mark:] {
|
||||||
|
who := string(m.ToUser)
|
||||||
|
if who == "" {
|
||||||
|
who = "room:" + string(m.ToRoom)
|
||||||
|
}
|
||||||
|
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||||||
|
}
|
||||||
|
mark = len(sink.msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
chars := runnableChars(t)
|
||||||
|
if len(chars) < 2 {
|
||||||
|
t.Fatalf("need 2+ runnable characters in the DB copy, got %d", len(chars))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pinned clock, 14:00 UTC today: outside the ambient quiet windows (06:00
|
||||||
|
// briefing / 21:00 recap) that fireExpeditionBoredom deliberately refuses to
|
||||||
|
// talk over, so the run is deterministic regardless of when we invoke it.
|
||||||
|
now := time.Now().UTC().Truncate(24 * time.Hour).Add(14 * time.Hour)
|
||||||
|
if inAmbientQuietWindow(now) {
|
||||||
|
t.Fatal("pinned clock landed in the quiet window — pick another hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the decks: nobody in the copy should be mid-expedition when we start,
|
||||||
|
// or the structural guards will (correctly) refuse and prove nothing.
|
||||||
|
for _, c := range chars {
|
||||||
|
clearExpeditionState(t, c.uid)
|
||||||
|
healToFull(t, c.uid)
|
||||||
|
euro.Credit(c.uid, 2000, "boredom exercise bankroll")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control arm. Without one, "everybody went" and "the clock works" look
|
||||||
|
// identical — and the failure mode we're hunting (playerIsIdle failing open)
|
||||||
|
// produces exactly the former.
|
||||||
|
active := chars[0]
|
||||||
|
idle := chars[1:]
|
||||||
|
setLastAction(t, active.uid, now.Add(-1*time.Hour))
|
||||||
|
for _, c := range idle {
|
||||||
|
setLastAction(t, c.uid, now.Add(-72*time.Hour))
|
||||||
|
}
|
||||||
|
// Everyone else in the DB is left alone but must not be dragged along either;
|
||||||
|
// snapshot who has an expedition now so we can diff at the end.
|
||||||
|
before := activeExpeditionOwners(t)
|
||||||
|
|
||||||
|
t.Logf("──────── clock ────────")
|
||||||
|
t.Logf(" control (acted 1h ago): %s L%d %s", active.uid, active.level, active.class)
|
||||||
|
for _, c := range idle {
|
||||||
|
t.Logf(" idle (72h silent): %s L%d %s", c.uid, c.level, c.class)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. The candidate query ──────────────────────────────────────────────
|
||||||
|
cands, err := loadBoredomCandidates(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadBoredomCandidates: %v", err)
|
||||||
|
}
|
||||||
|
candSet := map[id.UserID]bool{}
|
||||||
|
for _, u := range cands {
|
||||||
|
candSet[u] = true
|
||||||
|
}
|
||||||
|
t.Logf(" loadBoredomCandidates → %d players", len(cands))
|
||||||
|
if candSet[active.uid] {
|
||||||
|
t.Errorf("control %s acted an hour ago but is a boredom candidate — the idle clock is not being read", active.uid)
|
||||||
|
}
|
||||||
|
for _, c := range idle {
|
||||||
|
if !candSet[c.uid] {
|
||||||
|
t.Errorf("idle %s has been silent 72h but is not a candidate", c.uid)
|
||||||
|
}
|
||||||
|
if playerIsIdle(active.uid, now) {
|
||||||
|
t.Fatalf("playerIsIdle says the control player is idle — it is failing open, ABORT (this is the bug that marches the whole server into a dungeon)")
|
||||||
|
}
|
||||||
|
if !playerIsIdle(c.uid, now) {
|
||||||
|
t.Errorf("playerIsIdle(%s) = false after 72h of silence", c.uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2/3/4. The run itself ───────────────────────────────────────────────
|
||||||
|
type snap struct {
|
||||||
|
balance float64
|
||||||
|
gear string
|
||||||
|
}
|
||||||
|
pre := map[id.UserID]snap{}
|
||||||
|
for _, c := range chars {
|
||||||
|
pre[c.uid] = snap{balance: euro.GetBalance(c.uid), gear: gearFingerprint(t, c.uid)}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("──────── fireExpeditionBoredom ────────")
|
||||||
|
p.fireExpeditionBoredom(now)
|
||||||
|
drain()
|
||||||
|
|
||||||
|
for _, c := range idle {
|
||||||
|
exp, err := getActiveExpedition(c.uid)
|
||||||
|
if err != nil || exp == nil {
|
||||||
|
t.Errorf("%s (L%d): no expedition started (err=%v) — the boredom start path is broken", c.uid, c.level, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zone, ok := getZone(exp.ZoneID)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: started an unknown zone %q", c.uid, exp.ZoneID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
spent := pre[c.uid].balance - euro.GetBalance(c.uid)
|
||||||
|
lean := loadoutPurchase(zone.Tier, LoadoutLean)
|
||||||
|
t.Logf(" %s L%d → %s (T%d) — spent %.0f coins (lean = %d)",
|
||||||
|
c.uid, c.level, zone.Display, int(zone.Tier), spent, lean.Cost())
|
||||||
|
|
||||||
|
// In band.
|
||||||
|
if c.level < zone.LevelMin || c.level > zone.LevelMax {
|
||||||
|
t.Errorf("%s L%d sent to %s, band %d–%d — out of band",
|
||||||
|
c.uid, c.level, zone.ID, zone.LevelMin, zone.LevelMax)
|
||||||
|
}
|
||||||
|
// Lean supplies, exactly. Anything else means it went shopping.
|
||||||
|
if int(spent) != lean.Cost() {
|
||||||
|
t.Errorf("%s spent %.0f coins, lean pack for T%d is %d — it bought something it shouldn't have",
|
||||||
|
c.uid, spent, int(zone.Tier), lean.Cost())
|
||||||
|
}
|
||||||
|
// Gear untouched. This is the mechanic, not a nicety.
|
||||||
|
if got := gearFingerprint(t, c.uid); got != pre[c.uid].gear {
|
||||||
|
t.Errorf("%s: equipment changed across a bored run.\n was: %s\n now: %s", c.uid, pre[c.uid].gear, got)
|
||||||
|
}
|
||||||
|
// Flagged, so the idle reaper won't let it hold their streak.
|
||||||
|
if !expeditionIsBoredom(t, exp.ID) {
|
||||||
|
t.Errorf("%s: expedition %s not flagged boredom=1 — it will shield an absent player's streak", c.uid, exp.ID)
|
||||||
|
}
|
||||||
|
if !isBoredomDriven(c.uid, now) {
|
||||||
|
t.Errorf("isBoredomDriven(%s) = false right after a bored start", c.uid)
|
||||||
|
}
|
||||||
|
// Raid only where there was no alternative.
|
||||||
|
if w := raidContentWarning(zone.ID); w != "" && c.level < 13 {
|
||||||
|
t.Errorf("%s L%d was sent into raid zone %s while non-raid zones were in band", c.uid, c.level, zone.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control player must be exactly where we left them.
|
||||||
|
if exp, _ := getActiveExpedition(active.uid); exp != nil {
|
||||||
|
t.Errorf("control %s acted an hour ago and was still sent on an expedition (%s)", active.uid, exp.ZoneID)
|
||||||
|
}
|
||||||
|
if b := euro.GetBalance(active.uid); b != pre[active.uid].balance {
|
||||||
|
t.Errorf("control %s was charged %.0f coins for an expedition they didn't take",
|
||||||
|
active.uid, pre[active.uid].balance-b)
|
||||||
|
}
|
||||||
|
// The sweep reaches every idle player in the DB, not just the two we staged —
|
||||||
|
// that's the feature working, not a leak. The invariant that actually matters
|
||||||
|
// is that *everyone who left was idle*. (runnableChars only lists characters
|
||||||
|
// with the legacy adventure_characters row, so the DB holds idle players it
|
||||||
|
// doesn't return; they're still real characters and they're right to go.)
|
||||||
|
departed := diffOwners(activeExpeditionOwners(t), before)
|
||||||
|
for _, uid := range departed {
|
||||||
|
if !playerIsIdle(uid, now) {
|
||||||
|
t.Errorf("%s was sent on an expedition but is NOT idle — the clock is not gating the sweep", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf(" departed: %d of %d candidates", len(departed), len(cands))
|
||||||
|
// The gap between candidates and departures is the structural guards doing
|
||||||
|
// their job (mid-run, resting, unfinished setup, broke). Name them, so a
|
||||||
|
// silent refusal can't hide here.
|
||||||
|
left := map[id.UserID]bool{}
|
||||||
|
for _, uid := range departed {
|
||||||
|
left[uid] = true
|
||||||
|
}
|
||||||
|
for _, uid := range cands {
|
||||||
|
if !left[uid] {
|
||||||
|
t.Logf(" stayed home: %-28s %s", uid, whyNoBoredomRun(uid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. The cooldown ─────────────────────────────────────────────────────
|
||||||
|
t.Logf("──────── second tick (must be a no-op) ────────")
|
||||||
|
balBefore := map[id.UserID]float64{}
|
||||||
|
for _, c := range idle {
|
||||||
|
balBefore[c.uid] = euro.GetBalance(c.uid)
|
||||||
|
}
|
||||||
|
p.fireExpeditionBoredom(now.Add(30 * time.Minute))
|
||||||
|
drain()
|
||||||
|
for _, c := range idle {
|
||||||
|
if b := euro.GetBalance(c.uid); b != balBefore[c.uid] {
|
||||||
|
t.Errorf("%s was charged %.0f more coins on a second tick inside the cooldown — the CAS claim isn't holding",
|
||||||
|
c.uid, balBefore[c.uid]-b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Does it actually walk? ───────────────────────────────────────────
|
||||||
|
t.Logf("──────── autopilot walks the bored run ────────")
|
||||||
|
for _, c := range idle {
|
||||||
|
exp, _ := getActiveExpedition(c.uid)
|
||||||
|
if exp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx := MessageContext{Sender: c.uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: ""}
|
||||||
|
// Several segments, as the ambient ticker would over a day. One call can
|
||||||
|
// legitimately stop early (stopHarvestCombat, a fork, a boss doorway); the
|
||||||
|
// question is whether the run keeps making progress when it's called again.
|
||||||
|
total := 0
|
||||||
|
for seg := 0; seg < 4; seg++ {
|
||||||
|
r := p.runAutopilotWalkDriven(ctx, 6)
|
||||||
|
if r.initErr != "" {
|
||||||
|
t.Errorf("%s: autopilot refused to walk the bored expedition: %s", c.uid, r.initErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
total += r.rooms
|
||||||
|
t.Logf(" %s seg%d: +%d rooms (total %d), stopped: %s", c.uid, seg, r.rooms, total, stopName(r.reason))
|
||||||
|
if r.reason == stopComplete || r.reason == stopEnded {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
t.Errorf("%s: the bored expedition never took a single step", c.uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// clearExpeditionState puts the character back on the doorstep: no expedition,
|
||||||
|
// no zone run, no combat session. The prod copy is a live snapshot, so some
|
||||||
|
// characters are mid-run — and the structural guards would (correctly) refuse
|
||||||
|
// to start a bored run on top of that, which would test nothing.
|
||||||
|
func clearExpeditionState(t *testing.T, uid id.UserID) {
|
||||||
|
t.Helper()
|
||||||
|
for _, q := range []string{
|
||||||
|
`UPDATE dnd_expedition SET status = 'complete' WHERE user_id = ? AND status IN ('active','extracted')`,
|
||||||
|
`DELETE FROM dnd_zone_run WHERE user_id = ?`,
|
||||||
|
`DELETE FROM combat_session WHERE user_id = ?`,
|
||||||
|
`UPDATE player_meta SET last_boredom_at = NULL WHERE user_id = ?`,
|
||||||
|
} {
|
||||||
|
if _, err := db.Get().Exec(q, string(uid)); err != nil {
|
||||||
|
t.Logf(" clearExpeditionState(%s): %v (continuing)", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLastAction(t *testing.T, uid id.UserID, at time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
|
||||||
|
at, string(uid)); err != nil {
|
||||||
|
t.Fatalf("setLastAction(%s): %v", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gearFingerprint is what the bored run must not change.
|
||||||
|
func gearFingerprint(t *testing.T, uid id.UserID) string {
|
||||||
|
t.Helper()
|
||||||
|
eq, err := loadAdvEquipment(uid)
|
||||||
|
if err != nil {
|
||||||
|
return "ERR:" + err.Error()
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for slot, e := range eq {
|
||||||
|
if e == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%s/T%d", slot, e.Name, e.Tier))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return fmt.Sprint(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func expeditionIsBoredom(t *testing.T, expID string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var b bool
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT boredom FROM dnd_expedition WHERE expedition_id = ?`, expID).Scan(&b); err != nil {
|
||||||
|
t.Logf(" expeditionIsBoredom(%s): %v", expID, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeExpeditionOwners(t *testing.T) map[id.UserID]bool {
|
||||||
|
t.Helper()
|
||||||
|
out := map[id.UserID]bool{}
|
||||||
|
rows, err := db.Get().Query(`SELECT user_id FROM dnd_expedition WHERE status = 'active'`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("activeExpeditionOwners: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var s string
|
||||||
|
if err := rows.Scan(&s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out[id.UserID(s)] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// whyNoBoredomRun re-runs tryBoredomStart's guards for a candidate who didn't
|
||||||
|
// leave, so the candidates-vs-departures gap is explained rather than assumed.
|
||||||
|
func whyNoBoredomRun(uid id.UserID) string {
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return "character won't load: " + err.Error()
|
||||||
|
case c == nil:
|
||||||
|
return "no character"
|
||||||
|
case c.PendingSetup:
|
||||||
|
return "character creation never finished"
|
||||||
|
}
|
||||||
|
if restingLockoutRemaining(c) > 0 {
|
||||||
|
return "resting lockout"
|
||||||
|
}
|
||||||
|
if hasActiveCombatSession(uid) {
|
||||||
|
return "in combat"
|
||||||
|
}
|
||||||
|
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||||||
|
return "seated on someone else's party"
|
||||||
|
}
|
||||||
|
if active, _ := getActiveExpedition(uid); active != nil {
|
||||||
|
return "already on an expedition"
|
||||||
|
}
|
||||||
|
if pending, _ := getResumableExpedition(uid); pending != nil {
|
||||||
|
return "extracted, waiting on !resume"
|
||||||
|
}
|
||||||
|
if run, _ := getActiveZoneRun(uid); run != nil {
|
||||||
|
return "mid zone run"
|
||||||
|
}
|
||||||
|
zone, ok := pickBoredomZone(uid, c.Level)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Sprintf("no zone in band for L%d", c.Level)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("(no guard tripped — would have gone to %s; check cooldown/coins)", zone.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopName(r stopReason) string {
|
||||||
|
switch r {
|
||||||
|
case stopOK:
|
||||||
|
return "ok"
|
||||||
|
case stopFork:
|
||||||
|
return "fork"
|
||||||
|
case stopElite:
|
||||||
|
return "elite doorway"
|
||||||
|
case stopBoss:
|
||||||
|
return "boss doorway"
|
||||||
|
case stopEnded:
|
||||||
|
return "died"
|
||||||
|
case stopComplete:
|
||||||
|
return "zone cleared"
|
||||||
|
case stopBlocked:
|
||||||
|
return "blocked by combat session"
|
||||||
|
case stopHarvestCombat:
|
||||||
|
return "harvest pulled a fight"
|
||||||
|
case stopPreflight:
|
||||||
|
return "preflight (low HP/SU)"
|
||||||
|
case stopBossSafety:
|
||||||
|
return "boss-safety gate"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("stopReason(%d)", int(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffOwners(after, before map[id.UserID]bool) []id.UserID {
|
||||||
|
var out []id.UserID
|
||||||
|
for uid := range after {
|
||||||
|
if !before[uid] {
|
||||||
|
out = append(out, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
459
internal/plugin/expedition_boredom.go
Normal file
459
internal/plugin/expedition_boredom.go
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/flavor"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bored adventurers (gogobee_boredom_plan.md).
|
||||||
|
//
|
||||||
|
// A player who stops tending their adventurer doesn't stop having one. After
|
||||||
|
// boredomIdleThreshold with no action against Adventure, the character gets
|
||||||
|
// restless and leaves on an expedition by itself: the easiest zone its level
|
||||||
|
// band allows, the cheapest supplies it can afford, and whatever gear was
|
||||||
|
// already on the rack.
|
||||||
|
//
|
||||||
|
// Everything downstream of the start is already autonomous — the autopilot
|
||||||
|
// (expedition_autorun.go) walks rooms, drives elite and boss fights on the real
|
||||||
|
// turn engine, camps, harvests, rolls days over, and auto-picks forks after 8h.
|
||||||
|
// The only thing that ever needed a human was `!expedition start`, so that is
|
||||||
|
// the only thing this file does.
|
||||||
|
//
|
||||||
|
// It never buys or equips gear, and that is the entire mechanic: a neglected
|
||||||
|
// adventurer grinds half-starved runs on rusting equipment and comes home taxed.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// boredomIdleThreshold — silence, measured against the player's last
|
||||||
|
// action *against Adventure*, before the adventurer leaves on its own.
|
||||||
|
boredomIdleThreshold = 24 * time.Hour
|
||||||
|
|
||||||
|
// boredomCooldown — minimum gap between boredom starts for one player.
|
||||||
|
// Also the re-trigger cadence: when a bored expedition ends, another
|
||||||
|
// boredomCooldown of silence starts the next one. An abandoned character
|
||||||
|
// keeps going until it runs out of coins for supplies.
|
||||||
|
boredomCooldown = 24 * time.Hour
|
||||||
|
|
||||||
|
// boredomTickInterval — how often the ticker wakes. The cooldown gate is
|
||||||
|
// what actually paces starts; the tick only has to be frequent enough to
|
||||||
|
// enforce it cleanly.
|
||||||
|
boredomTickInterval = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── The idle clock ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// markPlayerAction stamps player_meta.last_player_action_at — the clock the
|
||||||
|
// boredom ticker reads.
|
||||||
|
//
|
||||||
|
// It is a direct UPDATE and deliberately does NOT route through
|
||||||
|
// saveAdvCharacter. Every other candidate timestamp in the schema is unusable
|
||||||
|
// here for the same reason (gogobee_boredom_plan.md §1):
|
||||||
|
//
|
||||||
|
// - last_active_at is auto-bumped inside saveAdvCharacter, so the autopilot's
|
||||||
|
// own combat writes would refresh a bored character's idle clock and it
|
||||||
|
// would never qualify again.
|
||||||
|
// - user_stats.updated_at is bumped by any Matrix message in any room. That's
|
||||||
|
// chat presence, not a game action — a player can talk all day without
|
||||||
|
// touching their adventurer, and the adventurer should still get bored.
|
||||||
|
// - loadAdvDailyActivity is unified across dnd_expedition_log, so the
|
||||||
|
// autopilot's own walk entries read back as player activity.
|
||||||
|
//
|
||||||
|
// Any future non-Matrix entry point (web, API, Pete) should call this too — the
|
||||||
|
// clock tracks actions against Adventure, not Matrix traffic.
|
||||||
|
func markPlayerAction(userID id.UserID) {
|
||||||
|
db.Exec("boredom: mark player action",
|
||||||
|
`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
|
||||||
|
time.Now().UTC(), string(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// advActionCommands — every `!command` routed by AdventurePlugin.OnMessage.
|
||||||
|
//
|
||||||
|
// This is NOT Commands() (adventure.go): that registers 28 names for the help
|
||||||
|
// surface and is missing expedition, zone, fight, camp, extract, resume and
|
||||||
|
// more. Routing is a 47-branch if-chain, and the boredom clock has to see all
|
||||||
|
// of it, so the set is spelled out here. TestAdvActionCommandsCoverDispatch
|
||||||
|
// greps the IsCommand literals out of adventure.go and fails if one is missing,
|
||||||
|
// so a new command can't silently fall out of the clock.
|
||||||
|
var advActionCommands = map[string]bool{
|
||||||
|
"abilities": true, "adv": true, "adventure": true, "arena": true,
|
||||||
|
"arm": true, "attack": true, "bail": true, "camp": true,
|
||||||
|
"cast": true, "check": true, "commune": true, "consume": true,
|
||||||
|
"craft": true, "duel": true, "essence": true, "expedition": true,
|
||||||
|
"explore": true, "extract": true, "fight": true, "fish": true,
|
||||||
|
"flee": true, "forage": true, "give": true, "graveyard": true,
|
||||||
|
"hospital": true, "level": true, "lore": true, "map": true,
|
||||||
|
"mine": true, "mischief": true, "news": true, "prepare": true, "region": true,
|
||||||
|
"resources": true, "respec": true, "rest": true, "resume": true,
|
||||||
|
"revisit": true, "rivals": true, "roll": true, "scavenge": true,
|
||||||
|
"sell": true, "setup": true, "sheet": true, "spells": true,
|
||||||
|
"stats": true, "subclass": true, "thom": true, "threat": true,
|
||||||
|
"town": true, "zone": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlayerAdventureAction reports whether this message is the player doing
|
||||||
|
// something to their adventurer — an Adventure command, or a reply to a prompt
|
||||||
|
// we're holding open for them (shop, blacksmith, pet, treasure). Both count:
|
||||||
|
// answering "2" to a shop menu is as much an action as typing `!shop`.
|
||||||
|
//
|
||||||
|
// This runs on every message the bot sees, in every room, so the command test
|
||||||
|
// is a single tokenise-and-look-up rather than 50 passes of IsCommand over the
|
||||||
|
// body. It matches util.IsCommand's rule exactly: trimmed, lowercased, the
|
||||||
|
// command is the whole body or is followed by a space.
|
||||||
|
//
|
||||||
|
// The pending-prompt check honours ExpiresAt. Nothing sweeps p.pending, so an
|
||||||
|
// abandoned prompt (offered, never answered) sits there forever — without the
|
||||||
|
// expiry test, every idle remark that player ever makes in any room would read
|
||||||
|
// as tending their adventurer, and the clock would never run down.
|
||||||
|
func (p *AdventurePlugin) isPlayerAdventureAction(ctx MessageContext) bool {
|
||||||
|
body := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||||
|
if strings.HasPrefix(body, p.Prefix) {
|
||||||
|
word := strings.TrimPrefix(body, p.Prefix)
|
||||||
|
if i := strings.IndexByte(word, ' '); i >= 0 {
|
||||||
|
word = word[:i]
|
||||||
|
}
|
||||||
|
if advActionCommands[word] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := p.pending.Load(string(ctx.Sender)); ok {
|
||||||
|
pi, isPrompt := v.(*advPendingInteraction)
|
||||||
|
// A zero ExpiresAt is a prompt with no deadline, not an expired one.
|
||||||
|
if isPrompt && (pi.ExpiresAt.IsZero() || time.Now().Before(pi.ExpiresAt)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The ticker ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) expeditionBoredomTicker() {
|
||||||
|
ticker := time.NewTicker(boredomTickInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
p.fireExpeditionBoredom(time.Now().UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireExpeditionBoredom sends every eligible idle adventurer into a dungeon.
|
||||||
|
func (p *AdventurePlugin) fireExpeditionBoredom(now time.Time) {
|
||||||
|
// Same politeness gate as the ambient ticker: don't drop a departure DM on
|
||||||
|
// top of the 06:00 briefing or 21:00 recap.
|
||||||
|
if inAmbientQuietWindow(now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uids, err := loadBoredomCandidates(now)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("boredom: load candidates", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, uid := range uids {
|
||||||
|
if err := p.tryBoredomStart(uid, now); err != nil {
|
||||||
|
slog.Warn("boredom: start failed", "user", uid, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadBoredomCandidates returns players whose last action against Adventure is
|
||||||
|
// older than boredomIdleThreshold and who haven't been sent out inside the
|
||||||
|
// cooldown.
|
||||||
|
//
|
||||||
|
// COALESCE onto created_at so a character that was made and then never played
|
||||||
|
// still qualifies — but not on its very first tick, since created_at is the
|
||||||
|
// clock until they touch anything.
|
||||||
|
func loadBoredomCandidates(now time.Time) ([]id.UserID, error) {
|
||||||
|
idleCutoff := now.Add(-boredomIdleThreshold)
|
||||||
|
coolCutoff := now.Add(-boredomCooldown)
|
||||||
|
rows, err := db.Get().Query(`
|
||||||
|
SELECT user_id
|
||||||
|
FROM player_meta
|
||||||
|
WHERE alive = 1
|
||||||
|
AND COALESCE(last_player_action_at, created_at) IS NOT NULL
|
||||||
|
AND COALESCE(last_player_action_at, created_at) < ?
|
||||||
|
AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
|
||||||
|
idleCutoff, coolCutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []id.UserID
|
||||||
|
for rows.Next() {
|
||||||
|
var s string
|
||||||
|
if err := rows.Scan(&s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, id.UserID(s))
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryBoredomStart runs the eligibility guards for one player and, if they pass,
|
||||||
|
// outfits and launches the expedition.
|
||||||
|
func (p *AdventurePlugin) tryBoredomStart(uid id.UserID, now time.Time) error {
|
||||||
|
// The foreground commands mutate the same rows under this lock; a boredom
|
||||||
|
// start racing `!expedition start` would double-book the character.
|
||||||
|
userMu := p.advUserLock(uid)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c == nil || c.PendingSetup {
|
||||||
|
return nil // never finished character creation — nothing to get bored
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural blockers. Every one of these is a state the player has to
|
||||||
|
// resolve themselves; the adventurer can't just walk out of it. Mirrors the
|
||||||
|
// guard set in expeditionCmdStart.
|
||||||
|
if restingLockoutRemaining(c) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if hasActiveCombatSession(uid) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||||||
|
return nil // riding someone else's party
|
||||||
|
}
|
||||||
|
if active, _ := getActiveExpedition(uid); active != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if pending, _ := getResumableExpedition(uid); pending != nil {
|
||||||
|
return nil // extracted, holding a roster for `!resume` — theirs to settle
|
||||||
|
}
|
||||||
|
if run, _ := getActiveZoneRun(uid); run != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
zone, ok := pickBoredomZone(uid, c.Level)
|
||||||
|
if !ok {
|
||||||
|
return nil // no zone this character's level band can be sent to
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the cooldown BEFORE spending anything. If the start then fails, or
|
||||||
|
// the process dies mid-flight, we wait out the cooldown instead of looping
|
||||||
|
// on a broken character every 30 minutes.
|
||||||
|
res, err := db.Get().Exec(`
|
||||||
|
UPDATE player_meta
|
||||||
|
SET last_boredom_at = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
|
||||||
|
now, string(uid), now.Add(-boredomCooldown))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return nil // another tick claimed it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheapest pack that exists. A zero-supply start is impossible —
|
||||||
|
// SupplyPurchase.Validate rejects it — so a bored run always costs coins,
|
||||||
|
// and a broke adventurer simply doesn't go. That's the floor that stops an
|
||||||
|
// abandoned character looping forever.
|
||||||
|
purchase := loadoutPurchase(zone.Tier, LoadoutLean)
|
||||||
|
cost := float64(purchase.Cost())
|
||||||
|
if p.euro == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if balance := p.euro.GetBalance(uid); balance < cost {
|
||||||
|
return p.SendDM(uid, fmt.Sprintf(
|
||||||
|
"🪙 I got as far as the supply counter. Outfitting for **%s** runs **%d** coins and we have **%.0f**. So I've put the pack back and come home. Nothing to be done about it from where I'm standing.",
|
||||||
|
zone.Display, int(cost), balance))
|
||||||
|
}
|
||||||
|
|
||||||
|
exp, supplies, _, err := p.beginExpedition(uid, c.Level, zone, purchase, "expedition outfitting (restless)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark it as self-started. The idle reaper reads this so an expedition the
|
||||||
|
// player never asked for can't hold their streak or spare them the shame DM
|
||||||
|
// (gogobee_boredom_plan.md §6).
|
||||||
|
db.Exec("boredom: flag expedition",
|
||||||
|
`UPDATE dnd_expedition SET boredom = 1 WHERE expedition_id = ?`, exp.ID)
|
||||||
|
|
||||||
|
slog.Info("boredom: adventurer left on its own",
|
||||||
|
"user", uid, "zone", zone.ID, "level", c.Level, "cost", int(cost))
|
||||||
|
emitBoredomDeparture(uid, zone, c.Level)
|
||||||
|
return p.SendDM(uid, renderBoredomDepartureDM(zone, supplies, purchase))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reading the idle clock ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// playerIsIdle reports whether the player has gone boredomIdleThreshold without
|
||||||
|
// an action against Adventure. A player with no recorded action at all (never
|
||||||
|
// played, or predates the column) counts as idle.
|
||||||
|
// The COALESCE is done in Go, not SQL, on purpose. modernc.org/sqlite rebuilds
|
||||||
|
// a time.Time from the column's declared type, and COALESCE() erases it — the
|
||||||
|
// value comes back a string and the Scan fails. Selecting the two declared
|
||||||
|
// DATETIME columns and folding them here keeps the affinity. This function
|
||||||
|
// fails open (an unreadable row reads as idle), so a broken scan here would
|
||||||
|
// declare every player idle and march the whole server into a dungeon at once.
|
||||||
|
func playerIsIdle(userID id.UserID, now time.Time) bool {
|
||||||
|
var lastAction, created *time.Time
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT last_player_action_at, created_at FROM player_meta WHERE user_id = ?`,
|
||||||
|
string(userID)).Scan(&lastAction, &created)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
last := lastAction
|
||||||
|
if last == nil {
|
||||||
|
last = created // never acted — the clock runs from when they were made
|
||||||
|
}
|
||||||
|
if last == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return last.Before(now.Add(-boredomIdleThreshold))
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBoredomDriven reports that this character is running on its own and its
|
||||||
|
// player has not come back: the last expedition they set out on was one the
|
||||||
|
// adventurer started for itself, and they still haven't touched Adventure.
|
||||||
|
//
|
||||||
|
// This is the gate the idle reaper uses. It's deliberately narrower than "is
|
||||||
|
// the player idle": an expedition the player *did* start still holds their
|
||||||
|
// streak while the autopilot walks it, exactly as it always has. Only an
|
||||||
|
// expedition nobody asked for loses that protection.
|
||||||
|
//
|
||||||
|
// Reading the most recent expedition rather than the active one keeps it honest
|
||||||
|
// for the night after a bored run ends — the logs it left behind would
|
||||||
|
// otherwise read as player activity for another day.
|
||||||
|
func isBoredomDriven(userID id.UserID, now time.Time) bool {
|
||||||
|
if !playerIsIdle(userID, now) {
|
||||||
|
return false // they came back; nothing to reap
|
||||||
|
}
|
||||||
|
var boredom bool
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT boredom
|
||||||
|
FROM dnd_expedition
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY start_date DESC
|
||||||
|
LIMIT 1`, string(userID)).Scan(&boredom)
|
||||||
|
if err != nil {
|
||||||
|
return false // no expedition history, or unreadable — don't reap on a guess
|
||||||
|
}
|
||||||
|
return boredom
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zone selection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// pickBoredomZone chooses where a restless adventurer goes.
|
||||||
|
//
|
||||||
|
// It uses the LevelMin/LevelMax bands on ZoneDefinition rather than
|
||||||
|
// zonesForLevel, which filters on tier alone and ignores those fields — which
|
||||||
|
// is why a level-1 player can currently walk into a T3 zone. A bored adventurer
|
||||||
|
// should neither farm trivial content nor wander somewhere absurd, so the band
|
||||||
|
// is the right gate.
|
||||||
|
//
|
||||||
|
// Order of preference:
|
||||||
|
// 1. Zones whose level band contains the character.
|
||||||
|
// 2. Among those, the non-raid ones (raidContentWarning) — the party-tuned T5
|
||||||
|
// bosses have a 0% solo clear rate, so they're avoided while an alternative
|
||||||
|
// exists.
|
||||||
|
// 3. Among those, the lowest tier: the "easiest at-level" rule.
|
||||||
|
// 4. Ties break on least-recently-visited, so a bored adventurer rotates zones
|
||||||
|
// instead of grinding one forever.
|
||||||
|
//
|
||||||
|
// If step 2 leaves nothing, raid zones are allowed back in. That only bites at
|
||||||
|
// L13+, where dragons_lair and abyss_portal are the only zones in band and both
|
||||||
|
// are raids. Those runs cannot be won solo — they wall at the boss-safety gate,
|
||||||
|
// starve, and force-extract with the haul tax. That is the intended fate of an
|
||||||
|
// abandoned endgame character, and it drains the coins that fund the next one.
|
||||||
|
func pickBoredomZone(uid id.UserID, level int) (ZoneDefinition, bool) {
|
||||||
|
var inBand, nonRaid []ZoneDefinition
|
||||||
|
for _, z := range allZones() {
|
||||||
|
if level < z.LevelMin || level > z.LevelMax {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inBand = append(inBand, z)
|
||||||
|
if raidContentWarning(z.ID) == "" {
|
||||||
|
nonRaid = append(nonRaid, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pool := nonRaid
|
||||||
|
if len(pool) == 0 {
|
||||||
|
pool = inBand
|
||||||
|
}
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return ZoneDefinition{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVisit, err := lastExpeditionByZone(uid)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("boredom: zone history unavailable, falling back to tier order", "user", uid, "err", err)
|
||||||
|
lastVisit = map[ZoneID]time.Time{}
|
||||||
|
}
|
||||||
|
sort.SliceStable(pool, func(i, j int) bool {
|
||||||
|
if pool[i].Tier != pool[j].Tier {
|
||||||
|
return pool[i].Tier < pool[j].Tier
|
||||||
|
}
|
||||||
|
return lastVisit[pool[i].ID].Before(lastVisit[pool[j].ID])
|
||||||
|
})
|
||||||
|
return pool[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastExpeditionByZone returns when this player last set out for each zone.
|
||||||
|
// Zones they've never visited are absent, so they sort first (zero time).
|
||||||
|
//
|
||||||
|
// No MAX(start_date)/GROUP BY: the aggregate erases the column's declared type
|
||||||
|
// and modernc.org/sqlite hands back a string the Scan can't take. Walking the
|
||||||
|
// rows in ascending order and letting each overwrite the last gives the same
|
||||||
|
// answer with the affinity intact.
|
||||||
|
func lastExpeditionByZone(uid id.UserID) (map[ZoneID]time.Time, error) {
|
||||||
|
rows, err := db.Get().Query(`
|
||||||
|
SELECT zone_id, start_date
|
||||||
|
FROM dnd_expedition
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY start_date ASC`, string(uid))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[ZoneID]time.Time{}
|
||||||
|
for rows.Next() {
|
||||||
|
var zone string
|
||||||
|
var last time.Time
|
||||||
|
if err := rows.Scan(&zone, &last); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[ZoneID(zone)] = last // ascending, so the last write is the most recent
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// renderBoredomDepartureDM is the note left on the table. It states plainly
|
||||||
|
// that nothing was bought and nothing was upgraded — the player should be able
|
||||||
|
// to read this and know exactly why the haul is going to be bad.
|
||||||
|
func renderBoredomDepartureDM(zone ZoneDefinition, supplies ExpeditionSupplies, purchase SupplyPurchase) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("🎒 **Gone on ahead — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||||||
|
b.WriteString("_" + zone.Hook + "_\n\n")
|
||||||
|
b.WriteString(flavor.Pick(flavor.ExpeditionBoredomStart))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("**Packed:** %.0f SU (%d standard) — %d coins, the cheapest that would get us through the gate\n",
|
||||||
|
supplies.Max, purchase.StandardPacks, purchase.Cost()))
|
||||||
|
b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — roughly **%d days** before we're rationing\n",
|
||||||
|
supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn)))
|
||||||
|
b.WriteString("**Equipment:** unchanged. I don't do the shopping.\n\n")
|
||||||
|
if w := raidContentWarning(zone.ID); w != "" {
|
||||||
|
b.WriteString(w)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString("`!expedition status` to see where we've got to. Come and find us.")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
261
internal/plugin/expedition_boredom_clock_test.go
Normal file
261
internal/plugin/expedition_boredom_clock_test.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These exercise the idle clock against real rows.
|
||||||
|
//
|
||||||
|
// The hazard they exist for: modernc.org/sqlite rebuilds a time.Time from the
|
||||||
|
// column's *declared* type, and both COALESCE() and MAX() erase it. A Scan that
|
||||||
|
// silently fails here is not a small bug — playerIsIdle fails open (a player it
|
||||||
|
// can't read counts as idle), so a broken scan would declare the entire server
|
||||||
|
// idle and march everyone into a dungeon at once.
|
||||||
|
|
||||||
|
func seedBoredomPlayer(t *testing.T, uid id.UserID, created, lastAction, lastBoredom *time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at, last_boredom_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?)`,
|
||||||
|
string(uid), "Test", created, lastAction, lastBoredom)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed player_meta: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlayerIsIdleReadsTheClock(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-72 * time.Hour)
|
||||||
|
recent := now.Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
|
||||||
|
seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
|
||||||
|
// Never acted: the COALESCE falls through to created_at.
|
||||||
|
seedBoredomPlayer(t, "@never:test", &old, nil, nil)
|
||||||
|
seedBoredomPlayer(t, "@fresh:test", &recent, nil, nil)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
uid id.UserID
|
||||||
|
want bool
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"@idle:test", true, "last acted 72h ago"},
|
||||||
|
{"@active:test", false, "acted an hour ago"},
|
||||||
|
{"@never:test", true, "never acted, created 72h ago — COALESCE to created_at"},
|
||||||
|
{"@fresh:test", false, "never acted, but only just created — must not be yanked out on day one"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := playerIsIdle(tc.uid, now); got != tc.want {
|
||||||
|
t.Errorf("playerIsIdle(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadBoredomCandidatesRespectsClockAndCooldown(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-72 * time.Hour)
|
||||||
|
recent := now.Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
|
||||||
|
seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
|
||||||
|
// Idle, but already sent out an hour ago — the cooldown holds them back.
|
||||||
|
seedBoredomPlayer(t, "@cooling:test", &old, &old, &recent)
|
||||||
|
// Idle, and their last outing was days ago — eligible again.
|
||||||
|
seedBoredomPlayer(t, "@ready:test", &old, &old, &old)
|
||||||
|
|
||||||
|
got, err := loadBoredomCandidates(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadBoredomCandidates: %v", err)
|
||||||
|
}
|
||||||
|
set := map[id.UserID]bool{}
|
||||||
|
for _, u := range got {
|
||||||
|
set[u] = true
|
||||||
|
}
|
||||||
|
for uid, want := range map[id.UserID]bool{
|
||||||
|
"@idle:test": true,
|
||||||
|
"@ready:test": true,
|
||||||
|
"@active:test": false,
|
||||||
|
"@cooling:test": false,
|
||||||
|
} {
|
||||||
|
if set[uid] != want {
|
||||||
|
t.Errorf("candidate %s: got %v, want %v", uid, set[uid], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkPlayerActionResetsTheClock(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-72 * time.Hour)
|
||||||
|
seedBoredomPlayer(t, "@back:test", &old, &old, nil)
|
||||||
|
|
||||||
|
if !playerIsIdle("@back:test", now) {
|
||||||
|
t.Fatal("precondition: player should start idle")
|
||||||
|
}
|
||||||
|
markPlayerAction("@back:test")
|
||||||
|
if playerIsIdle("@back:test", now) {
|
||||||
|
t.Error("player acted against Adventure but is still counted idle — the clock isn't being stamped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsBoredomDrivenNeedsBothHalves pins the rule the idle reaper depends on:
|
||||||
|
// only an expedition the player never asked for, from a player who still hasn't
|
||||||
|
// come back, loses its streak protection. A self-started expedition holds it.
|
||||||
|
func TestIsBoredomDrivenNeedsBothHalves(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-72 * time.Hour)
|
||||||
|
recent := now.Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
seedExp := func(t *testing.T, uid id.UserID, boredom bool) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date, boredom)
|
||||||
|
VALUES (?, ?, 'goblin_warrens', 'active', ?, ?)`,
|
||||||
|
string(uid)+"-exp", string(uid), old, boredom)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed expedition: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedBoredomPlayer(t, "@bored:test", &old, &old, nil)
|
||||||
|
seedExp(t, "@bored:test", true)
|
||||||
|
|
||||||
|
seedBoredomPlayer(t, "@selfstart:test", &old, &old, nil)
|
||||||
|
seedExp(t, "@selfstart:test", false)
|
||||||
|
|
||||||
|
seedBoredomPlayer(t, "@returned:test", &old, &recent, nil)
|
||||||
|
seedExp(t, "@returned:test", true)
|
||||||
|
|
||||||
|
seedBoredomPlayer(t, "@noexp:test", &old, &old, nil)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
uid id.UserID
|
||||||
|
want bool
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"@bored:test", true, "absent player, expedition it started itself — reap"},
|
||||||
|
{"@selfstart:test", false, "absent, but they started this expedition themselves — the autopilot still holds their streak"},
|
||||||
|
{"@returned:test", false, "bored expedition, but the player came back — don't reap someone who's playing"},
|
||||||
|
{"@noexp:test", false, "no expedition history at all — don't reap on a guess"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := isBoredomDriven(tc.uid, now); got != tc.want {
|
||||||
|
t.Errorf("isBoredomDriven(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLastExpeditionByZoneScans covers the MAX(start_date) scan — the same
|
||||||
|
// type-affinity hazard as the COALESCE above, and the tie-breaker that stops a
|
||||||
|
// bored adventurer grinding one zone forever.
|
||||||
|
func TestLastExpeditionByZoneScans(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
uid := id.UserID("@hist:test")
|
||||||
|
older := time.Now().UTC().Add(-96 * time.Hour)
|
||||||
|
newer := time.Now().UTC().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
for i, e := range []struct {
|
||||||
|
zone string
|
||||||
|
when time.Time
|
||||||
|
}{
|
||||||
|
{"goblin_warrens", older},
|
||||||
|
{"goblin_warrens", newer},
|
||||||
|
{"crypt_valdris", older},
|
||||||
|
} {
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date)
|
||||||
|
VALUES (?, ?, ?, 'complete', ?)`,
|
||||||
|
string(uid)+string(rune('a'+i)), string(uid), e.zone, e.when); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := lastExpeditionByZone(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lastExpeditionByZone: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d zones, want 2 — the MAX(start_date) scan is dropping rows: %v", len(got), got)
|
||||||
|
}
|
||||||
|
if !got[ZoneGoblinWarrens].After(got[ZoneCryptValdris]) {
|
||||||
|
t.Errorf("goblin_warrens (%v) should be more recent than crypt_valdris (%v); "+
|
||||||
|
"the picker relies on this to rotate zones", got[ZoneGoblinWarrens], got[ZoneCryptValdris])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deploy this column ships in: last_player_action_at is NULL for everyone
|
||||||
|
// alive, and created_at is when the character was *made* — months ago for the
|
||||||
|
// regulars. Without a seed, the very first tick after restart reads the whole
|
||||||
|
// server as idle-since-creation and marches all of them into a dungeon, coins
|
||||||
|
// debited, including the player who typed a command a minute before the deploy.
|
||||||
|
func TestBootstrapSeedsClockSoADeployDoesNotEmptyTheTown(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
longAgo := now.Add(-90 * 24 * time.Hour)
|
||||||
|
recent := now.Add(-30 * time.Minute)
|
||||||
|
|
||||||
|
// A regular: made months ago, played half an hour before the deploy.
|
||||||
|
seedBoredomPlayerActive(t, "@regular:test", longAgo, &recent, nil)
|
||||||
|
// Genuinely abandoned: made months ago, last touched the game months ago.
|
||||||
|
seedBoredomPlayerActive(t, "@lapsed:test", longAgo, &longAgo, nil)
|
||||||
|
|
||||||
|
bootstrapBoredomClock()
|
||||||
|
|
||||||
|
uids, err := loadBoredomCandidates(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadBoredomCandidates: %v", err)
|
||||||
|
}
|
||||||
|
got := map[id.UserID]bool{}
|
||||||
|
for _, u := range uids {
|
||||||
|
got[u] = true
|
||||||
|
}
|
||||||
|
if got["@regular:test"] {
|
||||||
|
t.Error("a player who acted 30 minutes ago was sent into a dungeon by the deploy")
|
||||||
|
}
|
||||||
|
if !got["@lapsed:test"] {
|
||||||
|
t.Error("the abandoned character is exactly who this feature is for, and it skipped them")
|
||||||
|
}
|
||||||
|
if playerIsIdle("@regular:test", now) {
|
||||||
|
t.Error("playerIsIdle still reads an active player as idle — Robbie would go silent for them")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same seed, but the character is already out on a bored expedition
|
||||||
|
// (last_boredom_at set). Its own autopilot bumps last_active_at every day, so
|
||||||
|
// re-seeding from it on each restart would hand the character a fresh idle
|
||||||
|
// clock and boredom would never fire again.
|
||||||
|
func TestBootstrapLeavesAlreadyBoredCharactersAlone(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
longAgo := now.Add(-90 * 24 * time.Hour)
|
||||||
|
autopilotWrite := now.Add(-10 * time.Minute)
|
||||||
|
wentOut := now.Add(-48 * time.Hour)
|
||||||
|
|
||||||
|
seedBoredomPlayerActive(t, "@bored:test", longAgo, &autopilotWrite, &wentOut)
|
||||||
|
bootstrapBoredomClock()
|
||||||
|
|
||||||
|
if !playerIsIdle("@bored:test", now) {
|
||||||
|
t.Fatal("restart re-seeded a bored character's clock off its own autopilot writes; it would never get bored again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedBoredomPlayerActive seeds a row that predates the boredom column:
|
||||||
|
// last_player_action_at NULL, with a last_active_at the autopilot/saves have
|
||||||
|
// been keeping current.
|
||||||
|
func seedBoredomPlayerActive(t *testing.T, uid id.UserID, created time.Time, lastActive, lastBoredom *time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_active_at, last_player_action_at, last_boredom_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, NULL, ?)`,
|
||||||
|
string(uid), "Test", created, lastActive, lastBoredom)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed player_meta: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
internal/plugin/expedition_boredom_test.go
Normal file
95
internal/plugin/expedition_boredom_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pickBoredomZone reads expedition history to break ties, so it needs a DB.
|
||||||
|
func newBoredomTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvActionCommandsCoverDispatch pins the boredom idle clock to the actual
|
||||||
|
// command router.
|
||||||
|
//
|
||||||
|
// advActionCommands has to list every command AdventurePlugin.OnMessage routes,
|
||||||
|
// because a command missing from the set is a command the player can use
|
||||||
|
// without their adventurer noticing they showed up — so it sits there idle,
|
||||||
|
// gets "bored", and walks into a dungeon while they're actively playing.
|
||||||
|
//
|
||||||
|
// Commands() can't be used as the source of truth: it registers 28 names for
|
||||||
|
// the help surface and omits expedition, zone, fight, camp, extract and resume.
|
||||||
|
// So the router is the source of truth, and this greps it.
|
||||||
|
func TestAdvActionCommandsCoverDispatch(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("adventure.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read adventure.go: %v", err)
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`p\.IsCommand\(ctx\.Body, "([a-z0-9-]+)"\)`)
|
||||||
|
matches := re.FindAllSubmatch(src, -1)
|
||||||
|
if len(matches) < 40 {
|
||||||
|
t.Fatalf("only found %d IsCommand call sites in adventure.go — the regex has "+
|
||||||
|
"drifted from the dispatcher and this test is no longer guarding anything", len(matches))
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
name := string(m[1])
|
||||||
|
if !advActionCommands[name] {
|
||||||
|
t.Errorf("!%s is routed by OnMessage but missing from advActionCommands — "+
|
||||||
|
"players using it would not reset their boredom clock", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPickBoredomZoneBands walks the level bands and asserts the picker's
|
||||||
|
// rules: easiest at-level, no raid zones while an alternative exists, and the
|
||||||
|
// L13+ fallback into the raid.
|
||||||
|
func TestPickBoredomZoneBands(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
cases := []struct {
|
||||||
|
level int
|
||||||
|
want ZoneID
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{level: 1, want: ZoneGoblinWarrens, why: "T1 band, tie broken by never-visited order"},
|
||||||
|
{level: 5, want: ZoneForestShadows, why: "L5 is in both the T2 and T3 bands — take the easier"},
|
||||||
|
{level: 10, want: ZoneUnderdark, why: "T4 band; underdark is multi-region but NOT a raid, so it stays eligible. Both T4 zones are unvisited here, so the tie falls through to registration order"},
|
||||||
|
{level: 15, want: ZoneDragonsLair, why: "only raid zones in band at L15 — fallback fires, lowest tier first"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, ok := pickBoredomZone(id.UserID("@nobody:test"), tc.level)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("L%d: no zone picked (%s)", tc.level, tc.why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got.ID != tc.want {
|
||||||
|
t.Errorf("L%d: picked %s, want %s (%s)", tc.level, got.ID, tc.want, tc.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPickBoredomZoneAvoidsRaidsWhenPossible is the rule the player actually
|
||||||
|
// asked for, stated directly: below the endgame, a bored adventurer never gets
|
||||||
|
// sent into party-tuned content.
|
||||||
|
func TestPickBoredomZoneAvoidsRaidsWhenPossible(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
for level := 1; level <= 12; level++ {
|
||||||
|
z, ok := pickBoredomZone(id.UserID("@nobody:test"), level)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if raidContentWarning(z.ID) != "" {
|
||||||
|
t.Errorf("L%d: picked raid zone %s while a non-raid zone was in band", level, z.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
internal/plugin/expedition_companion_cmd.go
Normal file
115
internal/plugin/expedition_companion_cmd.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The player-facing surface for hiring Pete.
|
||||||
|
//
|
||||||
|
// !expedition hire [class] (leader, Day 1 — auto-fills the missing role)
|
||||||
|
// !expedition dismiss (leader — sends him home, no refund)
|
||||||
|
//
|
||||||
|
// He is hired on Day 1 like any other companion, because a body that materializes
|
||||||
|
// three rooms in is not a party member, it is a cheat code. See
|
||||||
|
// adventure_companion.go for why he is an NPC seat and not a player.
|
||||||
|
|
||||||
|
// expeditionCmdHire brings the correspondent along.
|
||||||
|
func (p *AdventurePlugin) expeditionCmdHire(ctx MessageContext, rest string) error {
|
||||||
|
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||||
|
}
|
||||||
|
if exp == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "No active expedition. `!expedition start <zone>` first.")
|
||||||
|
}
|
||||||
|
if !isLeader {
|
||||||
|
return p.SendDM(ctx.Sender, "Only the party leader hires. You're along for the ride.")
|
||||||
|
}
|
||||||
|
if !inviteWindowOpen(exp) {
|
||||||
|
return p.SendDM(ctx.Sender,
|
||||||
|
"This expedition has already set off — Pete joins on Day 1 or not at all.")
|
||||||
|
}
|
||||||
|
if companionSeated(exp.ID) {
|
||||||
|
return p.SendDM(ctx.Sender, "Pete's already with you. `!expedition party` to see the roster.")
|
||||||
|
}
|
||||||
|
if p.euro == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
||||||
|
}
|
||||||
|
|
||||||
|
zone := zoneOrFallback(exp.ZoneID)
|
||||||
|
level := companionPartyLevel(exp.ID)
|
||||||
|
|
||||||
|
class, explicit := parseCompanionClass(rest)
|
||||||
|
if !explicit {
|
||||||
|
if arg := strings.TrimSpace(rest); arg != "" && !strings.EqualFold(arg, "auto") {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"Don't know the class %q. Name one, or just `!expedition hire` and he'll fill whatever you're missing.", arg))
|
||||||
|
}
|
||||||
|
class = companionRoleFill(companionPartyClasses(exp.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
cost := float64(companionHireCost(level, zone.Tier))
|
||||||
|
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"Pete doesn't work for free. His fee for **%s** is **%d** — you have **%.0f**.",
|
||||||
|
zone.Display, int(cost), balance))
|
||||||
|
}
|
||||||
|
if !p.euro.Debit(ctx.Sender, cost, "expedition: hired Pete") {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't debit Pete's fee (try again).")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hireCompanion(exp.ID, class, level); err != nil {
|
||||||
|
p.euro.Credit(ctx.Sender, cost, "expedition: Pete hire refund")
|
||||||
|
return p.SendDM(ctx.Sender, companionRefusalText(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
emitCompanionHireFact(ctx.Sender, class, level, zone)
|
||||||
|
|
||||||
|
ci, _ := classInfo(class)
|
||||||
|
filled := "You asked for a " + ci.Display + "."
|
||||||
|
if !explicit {
|
||||||
|
filled = "He's filling the hole in your roster — **" + ci.Display + "**."
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"📝 **Pete's coming with you into %s.** %s He's **level %d**, and his fee was **%d**.\n\n"+
|
||||||
|
"He fights his own turns — you don't command him. He takes no loot and no XP; he's here for the story.\n"+
|
||||||
|
"`!expedition dismiss` sends him home (no refund — he's already packed).",
|
||||||
|
zone.Display, filled, level, int(cost)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// expeditionCmdDismiss sends him home mid-run.
|
||||||
|
func (p *AdventurePlugin) expeditionCmdDismiss(ctx MessageContext) error {
|
||||||
|
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||||
|
}
|
||||||
|
if exp == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "No active expedition.")
|
||||||
|
}
|
||||||
|
if !isLeader {
|
||||||
|
return p.SendDM(ctx.Sender, "Only the party leader can dismiss him.")
|
||||||
|
}
|
||||||
|
if err := dismissCompanion(exp.ID); err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, companionRefusalText(err))
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender,
|
||||||
|
"📝 **Pete heads back.** He got what he came for. The fee stays spent.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// companionRefusalText turns the companion layer's sentinel errors into copy.
|
||||||
|
func companionRefusalText(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrCompanionAlreadyHired):
|
||||||
|
return "Pete's already with you."
|
||||||
|
case errors.Is(err, ErrCompanionOnAssignment):
|
||||||
|
return "Pete's out on assignment with another party. He'll be back."
|
||||||
|
case errors.Is(err, ErrCompanionNotHired):
|
||||||
|
return "Pete isn't with you."
|
||||||
|
case errors.Is(err, ErrPartyFull):
|
||||||
|
return "No room — your party's full. Someone has to `!expedition leave` first."
|
||||||
|
default:
|
||||||
|
return "Couldn't hire Pete: " + err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,30 +82,126 @@ func partyMembers(expeditionID string) ([]PartyMember, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what
|
// PartySeatKind is what a seat *is*. The three answers differ in ways that matter
|
||||||
// the fan-out seams (digest, briefing, recap) want: a solo expedition yields
|
// at almost every seam, and conflating any two of them has already cost us a bug:
|
||||||
// just the owner, so a caller can loop unconditionally.
|
// a companion is a seat but not a mouth and not a mailbox; a leader owns the
|
||||||
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
|
// expedition row that everyone else references.
|
||||||
members, err := partyMembers(expeditionID)
|
type PartySeatKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeatLeader PartySeatKind = iota
|
||||||
|
SeatMember
|
||||||
|
SeatCompanion
|
||||||
|
)
|
||||||
|
|
||||||
|
// PartySeat is one body on an expedition.
|
||||||
|
type PartySeat struct {
|
||||||
|
UserID id.UserID
|
||||||
|
Kind PartySeatKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHuman reports whether there is a person behind this seat — someone who can be
|
||||||
|
// DM'd, can earn loot, eats supplies, and can die.
|
||||||
|
func (s PartySeat) IsHuman() bool { return s.Kind != SeatCompanion }
|
||||||
|
|
||||||
|
// expeditionParty is THE answer to "who is on this expedition". Every other view
|
||||||
|
// — who gets mail, who sits down in a fight, who eats — is derived from it.
|
||||||
|
//
|
||||||
|
// It ALWAYS includes the owner. That is the whole point, and it is not a
|
||||||
|
// convenience: a solo expedition has **no expedition_party rows at all** (the
|
||||||
|
// roster only materializes on the first invite — see partyMembers), so any code
|
||||||
|
// that answers "who is in this party?" by reading the roster table gets *nobody*
|
||||||
|
// for a solo player, and then quietly falls back to whatever looked sensible at
|
||||||
|
// the call site.
|
||||||
|
//
|
||||||
|
// That is not hypothetical. It is exactly how the hired companion came out at
|
||||||
|
// **level 1** for every solo player — the one player the feature exists for. The
|
||||||
|
// level was averaged over the party; the party read as empty; the fallback was 1.
|
||||||
|
// He then walked into a tier-4 zone as a level-1 body, died on contact, and left
|
||||||
|
// the leader fighting a boss that had been inflated on his account. A 1500-run
|
||||||
|
// sweep is what found it, because nothing else could.
|
||||||
|
//
|
||||||
|
// So: there is no way to ask this function for the party and be handed an empty
|
||||||
|
// list. If the expedition exists, it has at least a leader.
|
||||||
|
func expeditionParty(expeditionID, ownerID string) ([]PartySeat, error) {
|
||||||
|
rows, err := partyMembers(expeditionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(members) == 0 {
|
if len(rows) == 0 {
|
||||||
return []id.UserID{id.UserID(ownerID)}, nil
|
// Solo: no roster rows. The owner IS the party.
|
||||||
|
if ownerID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []PartySeat{{UserID: id.UserID(ownerID), Kind: SeatLeader}}, nil
|
||||||
}
|
}
|
||||||
out := make([]id.UserID, 0, len(members))
|
out := make([]PartySeat, 0, len(rows))
|
||||||
for _, m := range members {
|
for _, m := range rows {
|
||||||
out = append(out, id.UserID(m.UserID))
|
kind := SeatMember
|
||||||
|
switch {
|
||||||
|
case isCompanionUser(m.UserID):
|
||||||
|
kind = SeatCompanion
|
||||||
|
case m.IsLeader():
|
||||||
|
kind = SeatLeader
|
||||||
|
}
|
||||||
|
out = append(out, PartySeat{UserID: id.UserID(m.UserID), Kind: kind})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// partySize is the number of seated players: 1 for a solo expedition.
|
// partyHumans is the party minus the companion: everyone with a person behind
|
||||||
|
// them. This is the set that gets mail, earns loot, eats supplies, and can die.
|
||||||
|
func partyHumans(expeditionID, ownerID string) ([]PartySeat, error) {
|
||||||
|
seats, err := expeditionParty(expeditionID, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := seats[:0]
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.IsHuman() {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// partyMemberIDs is the whole party as ids, leader first — every body, companion
|
||||||
|
// included. Callers that want only people want partyHumans.
|
||||||
|
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
|
||||||
|
seats, err := expeditionParty(expeditionID, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]id.UserID, 0, len(seats))
|
||||||
|
for _, s := range seats {
|
||||||
|
out = append(out, s.UserID)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// partySize is the number of seated *players*: 1 for a solo expedition.
|
||||||
|
//
|
||||||
|
// A hired companion is not counted, and both of this function's consumers want
|
||||||
|
// it that way:
|
||||||
|
//
|
||||||
|
// - expeditionBurnRatePct scales the daily supply burn by party size. Pete
|
||||||
|
// never bought a pack — members buy their own on !expedition accept, and he
|
||||||
|
// accepts nothing — so counting him would bill the leader a 60% higher burn
|
||||||
|
// for a mouth that brought its own rations. He is on expenses.
|
||||||
|
// - the "your party is still waiting on you" gate blocks a leader from
|
||||||
|
// starting a new run while an extracted party is pending. A roster holding
|
||||||
|
// nobody but Pete is not a party waiting on anyone, and counting him would
|
||||||
|
// lock the leader out of the game until they abandoned the run.
|
||||||
|
//
|
||||||
|
// The combat roster is a different question with a different answer: Pete IS a
|
||||||
|
// body in the fight, so CombatSession.RosterSize() counts him and the enemy-HP
|
||||||
|
// scalar feels him. Seats and mouths are not the same set.
|
||||||
func partySize(expeditionID string) (int, error) {
|
func partySize(expeditionID string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := db.Get().QueryRow(
|
err := db.Get().QueryRow(
|
||||||
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
|
`SELECT COUNT(*) FROM expedition_party
|
||||||
expeditionID).Scan(&n)
|
WHERE expedition_id = ? AND user_id <> ?`,
|
||||||
|
expeditionID, string(companionUserID())).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,10 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
|||||||
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
|
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
|
||||||
}
|
}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
|
if isCompanionUser(m.UserID) {
|
||||||
|
b.WriteString(companionRosterLine(exp.ID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
role := "member"
|
role := "member"
|
||||||
if m.IsLeader() {
|
if m.IsLeader() {
|
||||||
role = "leader"
|
role = "leader"
|
||||||
@@ -260,6 +264,9 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
|||||||
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
|
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
|
||||||
if inviteWindowOpen(exp) {
|
if inviteWindowOpen(exp) {
|
||||||
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
|
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
|
||||||
|
if !companionSeated(exp.ID) {
|
||||||
|
b.WriteString("\n_Short a body? `!expedition hire` brings Pete along._")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,13 +108,45 @@ func seatedExpeditionFor(userID id.UserID) (*Expedition, error) {
|
|||||||
// degrades to the owner rather than dropping the message — a player who misses
|
// degrades to the owner rather than dropping the message — a player who misses
|
||||||
// their briefing because SQLite hiccuped is a worse outcome than a member who
|
// their briefing because SQLite hiccuped is a worse outcome than a member who
|
||||||
// misses theirs.
|
// misses theirs.
|
||||||
|
//
|
||||||
|
// A hired companion is dropped here, and this is the chokepoint that keeps him
|
||||||
|
// out of every DM seam at once: the briefing, the recap, the digest, the
|
||||||
|
// extraction notice — and, crucially, the per-member side effects that ride the
|
||||||
|
// fan-out rather than the message. maybeRollPetArrivalOnEmerge would offer Pete
|
||||||
|
// a pet and park a pending interaction awaiting a reply that never comes;
|
||||||
|
// maybeFireAnchoredEvent would claim him a daily event slot and DM him a
|
||||||
|
// choice. He is not a person; he does not get mail. See isCompanionSeat.
|
||||||
func expeditionAudience(e *Expedition) []id.UserID {
|
func expeditionAudience(e *Expedition) []id.UserID {
|
||||||
|
if e == nil || e.UserID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seats, err := partyHumans(e.ID, e.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("expedition: party roster read failed, DMing owner only",
|
||||||
|
"expedition", e.ID, "err", err)
|
||||||
|
return []id.UserID{id.UserID(e.UserID)}
|
||||||
|
}
|
||||||
|
out := make([]id.UserID, 0, len(seats))
|
||||||
|
for _, s := range seats {
|
||||||
|
out = append(out, s.UserID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// expeditionSeats is every body that sits down in a fight: the whole roster,
|
||||||
|
// hired companion included. It is deliberately NOT expeditionAudience — that one
|
||||||
|
// drops the companion because he does not get mail, and a combat roster built
|
||||||
|
// from it would seat everyone the leader paid for except the one he paid for.
|
||||||
|
//
|
||||||
|
// Mail and seats are different sets. Anything that sends is an audience;
|
||||||
|
// anything that fights is a seat.
|
||||||
|
func expeditionSeats(e *Expedition) []id.UserID {
|
||||||
if e == nil || e.UserID == "" {
|
if e == nil || e.UserID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ids, err := partyMemberIDs(e.ID, e.UserID)
|
ids, err := partyMemberIDs(e.ID, e.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("expedition: party roster read failed, DMing owner only",
|
slog.Warn("expedition: party roster read failed, seating owner only",
|
||||||
"expedition", e.ID, "err", err)
|
"expedition", e.ID, "err", err)
|
||||||
return []id.UserID{id.UserID(e.UserID)}
|
return []id.UserID{id.UserID(e.UserID)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") ==
|
|||||||
type SimRunner struct {
|
type SimRunner struct {
|
||||||
P *AdventurePlugin
|
P *AdventurePlugin
|
||||||
Euro *EuroPlugin
|
Euro *EuroPlugin
|
||||||
|
// Companion hires Pete into the party on Day 1: "" = none, "auto" = fill the
|
||||||
|
// missing role, or an explicit class id. He takes a combat seat and no loot,
|
||||||
|
// which is exactly the thing the sweep is here to measure — whether an extra
|
||||||
|
// below-median body lifts the trailing case without carrying it.
|
||||||
|
Companion string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs
|
// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs
|
||||||
@@ -400,7 +405,42 @@ type SimCombatSummary struct {
|
|||||||
// SetSimIncludeTrace(true) has been called, and only on the LAST
|
// SetSimIncludeTrace(true) has been called, and only on the LAST
|
||||||
// combat per expedition (the boss room) to keep JSONL size bounded.
|
// combat per expedition (the boss room) to keep JSONL size bounded.
|
||||||
// Used by J2 caster-survival analysis.
|
// Used by J2 caster-survival analysis.
|
||||||
Events []CombatEvent `json:",omitempty"`
|
Events []simTraceEvent `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// simTraceEvent is CombatEvent with the seat ALWAYS emitted.
|
||||||
|
//
|
||||||
|
// CombatEvent.Seat is `omitempty` for wire-compat reasons that are correct for
|
||||||
|
// persistence and wrong for a trace: seat 0 and "no seat" serialize identically,
|
||||||
|
// so a party trace silently reads as a solo fight. That is not hypothetical — it
|
||||||
|
// is what made a hired companion who never took a turn look, in the JSON, like a
|
||||||
|
// fight that only ever had one seat, and it cost an hour of chasing the wrong bug.
|
||||||
|
// A diagnostic that cannot tell you who acted is not a diagnostic.
|
||||||
|
type simTraceEvent struct {
|
||||||
|
Round int
|
||||||
|
Seat int // always present, even when 0
|
||||||
|
Phase string
|
||||||
|
Actor string
|
||||||
|
Action string
|
||||||
|
Damage int
|
||||||
|
PlayerHP int
|
||||||
|
EnemyHP int
|
||||||
|
Roll int
|
||||||
|
RollAgainst int
|
||||||
|
Desc string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// simTraceEvents converts a TurnLog for the trace.
|
||||||
|
func simTraceEvents(events []CombatEvent) []simTraceEvent {
|
||||||
|
out := make([]simTraceEvent, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
out[i] = simTraceEvent{
|
||||||
|
Round: e.Round, Seat: e.Seat, Phase: e.Phase, Actor: e.Actor, Action: e.Action,
|
||||||
|
Damage: e.Damage, PlayerHP: e.PlayerHP, EnemyHP: e.EnemyHP,
|
||||||
|
Roll: e.Roll, RollAgainst: e.RollAgainst, Desc: e.Desc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// simIncludeTrace gates per-round event capture on SimCombatSummary.
|
// simIncludeTrace gates per-round event capture on SimCombatSummary.
|
||||||
@@ -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")
|
return res, fmt.Errorf("expedition vanished while seating the party")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Hire the companion on Day 1, after the humans are seated so the role fill
|
||||||
|
// sees the party it is filling a hole in. He is not in `members` — he buys no
|
||||||
|
// packs and takes no seat in the human roster — but fightRoster reads
|
||||||
|
// expeditionSeats, so he shows up in every fight from here on.
|
||||||
|
if s.Companion != "" {
|
||||||
|
class, ok := parseCompanionClass(s.Companion)
|
||||||
|
if !ok {
|
||||||
|
class = companionRoleFill(companionPartyClasses(exp.ID))
|
||||||
|
}
|
||||||
|
if err := hireCompanion(exp.ID, class, companionPartyLevel(exp.ID)); err != nil {
|
||||||
|
res.Outcome = "halted"
|
||||||
|
res.StopCode = "companion:" + err.Error()
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
roster := append([]id.UserID{uid}, members...)
|
roster := append([]id.UserID{uid}, members...)
|
||||||
res.PartySize = len(roster)
|
res.PartySize = len(roster)
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
@@ -942,7 +998,7 @@ func simCombatSummaries(uid id.UserID) []SimCombatSummary {
|
|||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
if simIncludeTrace && len(out) > 0 {
|
if simIncludeTrace && len(out) > 0 {
|
||||||
out[len(out)-1].Events = lastEvents
|
out[len(out)-1].Events = simTraceEvents(lastEvents)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1094,18 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
|
|||||||
turn.Sender = id.UserID(cur.seatUserID(seat))
|
turn.Sender = id.UserID(cur.seatUserID(seat))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An engine-driven seat is never dispatched as a chat command. There is no
|
||||||
|
// character for the handlers to load, and a command arriving from that
|
||||||
|
// seat's id reads to beginCombatTurn as a player returning to the keyboard
|
||||||
|
// — which is how the driver used to clear the very latch that was moving
|
||||||
|
// the seat. Drive it directly instead.
|
||||||
|
if cur.seatIsEngineDriven(seat) {
|
||||||
|
if err := p.driveEngineSeat(cur, seat); err != nil {
|
||||||
|
return false, fmt.Errorf("engine seat %d: %w", seat, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
|
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
|
||||||
var dispatchErr error
|
var dispatchErr error
|
||||||
switch kind {
|
switch kind {
|
||||||
@@ -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
|
// which is seat 0. Driving a party member's turn off the leader's HP would heal
|
||||||
// the wrong person and re-arm the wrong aura.
|
// the wrong person and re-arm the wrong aura.
|
||||||
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
||||||
c, _ := LoadDnDCharacter(uid)
|
if sess == nil {
|
||||||
if c == nil || sess == nil {
|
return "attack", ""
|
||||||
|
}
|
||||||
|
// seatPickSheet, not LoadDnDCharacter: the hired companion has no character row
|
||||||
|
// and never will, so the raw load returned nil for him and this function bailed
|
||||||
|
// to "attack" on its first line — every turn, for the whole fight. That one line
|
||||||
|
// is why a hired Cleric swung a mace while the party died. He fights off the
|
||||||
|
// same synthetic sheet the rest of his seat is built from.
|
||||||
|
c := seatPickSheet(sess, uid)
|
||||||
|
if c == nil {
|
||||||
return "attack", ""
|
return "attack", ""
|
||||||
}
|
}
|
||||||
st := sess.actorStatusesForSeat(seat)
|
st := sess.actorStatusesForSeat(seat)
|
||||||
@@ -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) {
|
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
|
||||||
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
||||||
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
||||||
@@ -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
|
// otherwise never spends an L2 slot on it. Force the pick once
|
||||||
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
||||||
// it's not a damage option.
|
// it's not a damage option.
|
||||||
if id := simPickSpiritualWeapon(c, uid, st); id != "" {
|
if id := simPickSpiritualWeapon(c, sess, seat, uid, st); id != "" {
|
||||||
return "cast", id
|
return "cast", id
|
||||||
}
|
}
|
||||||
// Once a concentration aura is up, a competent caster maintains it and
|
// Once a concentration aura is up, a competent caster maintains it and
|
||||||
@@ -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
|
// slot to re-arm the same aura — so the picker excludes concentration
|
||||||
// spells while one is active.
|
// spells while one is active.
|
||||||
auraActive := st.ConcentrationDmg > 0
|
auraActive := st.ConcentrationDmg > 0
|
||||||
if id := simPickSpell(c, uid, auraActive); id != "" {
|
if id := simPickSpell(c, sess, seat, uid, auraActive); id != "" {
|
||||||
return "cast", id
|
return "cast", id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
|
||||||
// not worth burning the bigger slot's damage potential elsewhere; sim
|
// not worth burning the bigger slot's damage potential elsewhere; sim
|
||||||
// behaves like a competent player and saves the high slot.
|
// behaves like a competent player and saves the high slot.
|
||||||
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string {
|
func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, st ActorStatuses) string {
|
||||||
if c == nil || c.Class != ClassCleric {
|
if c == nil || c.Class != ClassCleric {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if st.BuffSpiritProc > 0 {
|
if st.BuffSpiritProc > 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
known, err := listKnownSpells(uid)
|
known, err := seatKnownSpells(sess, seat, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -1228,7 +1318,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
|||||||
if !prepared {
|
if !prepared {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
slots, _ := getSpellSlots(uid)
|
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||||
const simMaxSlot = 5
|
const simMaxSlot = 5
|
||||||
for sl := 2; sl <= simMaxSlot; sl++ {
|
for sl := 2; sl <= simMaxSlot; sl++ {
|
||||||
pair, ok := slots[sl]
|
pair, ok := slots[sl]
|
||||||
@@ -1261,12 +1351,111 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
|||||||
// - Among feasible candidates, prefer higher slot level (preserves
|
// - Among feasible candidates, prefer higher slot level (preserves
|
||||||
// high-slot supremacy and burns the big slots first); tie-break on
|
// high-slot supremacy and burns the big slots first); tie-break on
|
||||||
// expected damage from the dice string.
|
// expected damage from the dice string.
|
||||||
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
|
//
|
||||||
known, err := listKnownSpells(uid)
|
// simHealAllyThresholdPct is how hurt a friend has to be before a healer spends a
|
||||||
|
// slot on them rather than on damage. Deliberately lower than the self-heal
|
||||||
|
// threshold: a heal is a wasted turn if the target was going to live anyway, and
|
||||||
|
// the party's damage output is what ends the fight.
|
||||||
|
const simHealAllyThresholdPct = 45
|
||||||
|
|
||||||
|
// simPickHeal returns the heal spell a competent healer would cast this turn, and
|
||||||
|
// the seat to put it on — which may be the healer's own. An empty spell id means
|
||||||
|
// nobody needs healing, or this character cannot heal.
|
||||||
|
//
|
||||||
|
// Returning target "" means "cast it on yourself": the caller drops the @mention,
|
||||||
|
// and the engine's ordinary self-heal path takes it.
|
||||||
|
//
|
||||||
|
// It considers the caster's own seat, and it runs for a solo fight, and BOTH of
|
||||||
|
// those are recent. Until now the rule skipped `i == seat` and bailed on
|
||||||
|
// `!sess.IsParty()`, which together meant something nobody had said out loud: **no
|
||||||
|
// autopiloted caster in this game had ever cast a heal on themselves.** A solo
|
||||||
|
// cleric carried cure_wounds for a whole 30-room run and used it exactly never,
|
||||||
|
// while dying with a full pool of level-1 slots. That is a strong candidate for
|
||||||
|
// why the class corpus has cleric at 46–56% against fighter's 82%
|
||||||
|
// ([[project_d8prereq_baseline]], §6 of the combat-engine plan) — the picker was
|
||||||
|
// not playing the class.
|
||||||
|
//
|
||||||
|
// The healer heals whoever is worst off. Usually that is the friend bleeding out
|
||||||
|
// next to them. Sometimes it is them.
|
||||||
|
func simPickHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
|
||||||
|
if sess == nil || c == nil || !isSpellcaster(c) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Who is worst off? Only living seats — the engine will not raise the downed,
|
||||||
|
// and a heal aimed at a corpse is a lost turn.
|
||||||
|
worst, worstPct := -1, 101
|
||||||
|
for i := range sess.RosterSize() {
|
||||||
|
hp, hpMax := sess.seatHP(i), sess.seatHPMax(i)
|
||||||
|
if hp <= 0 || hpMax <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pct := hp * 100 / hpMax; pct < worstPct {
|
||||||
|
worst, worstPct = i, pct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worst < 0 || worstPct >= simHealAllyThresholdPct {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
// Healing yourself is not an ally-target at all — it is the plain self-heal the
|
||||||
|
// engine has always had. Hand the caller no target and let it say so.
|
||||||
|
if worst == seat {
|
||||||
|
return simPickHealSpell(c, uid, sess, seat), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
best := simPickHealSpell(c, uid, sess, seat)
|
||||||
|
if best == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
|
||||||
|
// the seats in this fight, so this round-trips without a room lookup.
|
||||||
|
return best, id.UserID(sess.seatUserID(worst)).Localpart()
|
||||||
|
}
|
||||||
|
|
||||||
|
// simPickHealSpell is the cheapest heal this caster can actually cast right now,
|
||||||
|
// or "". Burning a 5th-level slot to top somebody up is not what a competent
|
||||||
|
// player does, so it takes the lowest castable slot.
|
||||||
|
func simPickHealSpell(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) string {
|
||||||
|
known, err := seatKnownSpells(sess, seat, uid)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||||
|
best, bestLevel := "", 99
|
||||||
|
for _, k := range known {
|
||||||
|
if !k.Prepared {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp, ok := lookupSpell(k.SpellID)
|
||||||
|
if !ok || sp.Effect != EffectSpellHeal || sp.CastTime == CastReaction {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
onList := false
|
||||||
|
for _, cl := range sp.Classes {
|
||||||
|
if cl == c.Class {
|
||||||
|
onList = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !onList || sp.Level == 0 {
|
||||||
|
continue // heals are slot spells; a level-0 "heal" is not a thing we cast
|
||||||
|
}
|
||||||
|
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sp.Level < bestLevel {
|
||||||
|
best, bestLevel = sp.ID, sp.Level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {
|
||||||
|
known, err := seatKnownSpells(sess, seat, uid)
|
||||||
if err != nil || len(known) == 0 {
|
if err != nil || len(known) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
slots, _ := getSpellSlots(uid)
|
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||||
type cand struct {
|
type cand struct {
|
||||||
id string
|
id string
|
||||||
slot int
|
slot int
|
||||||
|
|||||||
148
internal/plugin/mischief_delivery_sweep_test.go
Normal file
148
internal/plugin/mischief_delivery_sweep_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// M1 close-out sweep for gogobee_mischief_plan.md — survival per tier through the
|
||||||
|
// REAL delivery path (runMischiefInterrupt → runZoneCombatRoster), not through
|
||||||
|
// SimulateCombat.
|
||||||
|
//
|
||||||
|
// The M0 sweep that priced the fee table measured a full-HP build in a single
|
||||||
|
// chain. Two things it could not see, and both of them are why this exists:
|
||||||
|
//
|
||||||
|
// - A real target is WOUNDED. The monster arrives mid-run, after the dungeon has
|
||||||
|
// already taken a bite out of them. The entryHP arm is the control-vs-treatment
|
||||||
|
// axis: 100% reproduces M0 through the new path (so a divergence there is a
|
||||||
|
// path bug, not a difficulty finding), 70% and 40% are what a live delivery
|
||||||
|
// actually lands on.
|
||||||
|
// - The M2 ward. `blessings` drives the same temp-HP cushion the room buys, so
|
||||||
|
// we can price what €75 of charity is actually worth against an elite.
|
||||||
|
//
|
||||||
|
// Run (heavy — see the plan's sim rules, n≥750 per cell for a real signal):
|
||||||
|
//
|
||||||
|
// MISCHIEF_SWEEP=1 go test ./internal/plugin -run TestMischiefDeliverySweep -v -timeout 4h
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mischiefDeliveryTrial seeds a real adventurer on a real expedition at the given
|
||||||
|
// fraction of their max HP, sends them the tier's monster through the production
|
||||||
|
// delivery path, and reports whether they were still standing.
|
||||||
|
//
|
||||||
|
// Survival is read the way the delivery reads it (HP > 0), not as "won every
|
||||||
|
// fight" — an engine timeout with HP left is a target who held the thing off.
|
||||||
|
func mischiefDeliveryTrial(
|
||||||
|
t *testing.T, p *AdventurePlugin, prof classBalanceProfile,
|
||||||
|
tierKey string, entryHPPct float64, blessings int, seq int, rng *rand.Rand,
|
||||||
|
) (survived bool, endHPPct float64) {
|
||||||
|
t.Helper()
|
||||||
|
uid := id.UserID(fmt.Sprintf("@sweep%d:sim", seq))
|
||||||
|
|
||||||
|
c := buildHarnessCharacter(prof)
|
||||||
|
c.UserID = uid
|
||||||
|
c.HPCurrent = int(float64(c.HPMax) * entryHPPct)
|
||||||
|
if c.HPCurrent < 1 {
|
||||||
|
c.HPCurrent = 1
|
||||||
|
}
|
||||||
|
if err := createAdvCharacter(uid, "sweep"+strconv.Itoa(seq)); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, prof.Level, rng)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startZoneRun: %v", err)
|
||||||
|
}
|
||||||
|
expID := "exp-sweep-" + strconv.Itoa(seq)
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, coins_earned)
|
||||||
|
VALUES (?, ?, 'goblin_warrens', ?, 'active', ?, 0)`,
|
||||||
|
expID, string(uid), run.RunID, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("seed expedition: %v", err)
|
||||||
|
}
|
||||||
|
exp, err := getActiveExpedition(uid)
|
||||||
|
if err != nil || exp == nil {
|
||||||
|
t.Fatalf("getActiveExpedition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bracket := mischiefBracketZone(prof.Level)
|
||||||
|
chain, ambush := mischiefMonsters(tierKey, bracket, rng)
|
||||||
|
|
||||||
|
_, _, survived = p.runMischiefInterrupt(uid, exp, bracket, chain, ambush, blessings)
|
||||||
|
hp, max := dndHPSnapshot(uid)
|
||||||
|
if max <= 0 {
|
||||||
|
max = 1
|
||||||
|
}
|
||||||
|
return survived, float64(hp) / float64(max)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefDeliverySweep(t *testing.T) {
|
||||||
|
if os.Getenv("MISCHIEF_SWEEP") == "" {
|
||||||
|
t.Skip("set MISCHIEF_SWEEP=1 to run")
|
||||||
|
}
|
||||||
|
trials := 250
|
||||||
|
if n := os.Getenv("MISCHIEF_TRIALS"); n != "" {
|
||||||
|
if v, err := strconv.Atoi(n); err == nil && v > 0 {
|
||||||
|
trials = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
p.Sink = &captureSink{}
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewPCG(20260713, 0x64656C6976657279))
|
||||||
|
profiles := []classBalanceProfile{
|
||||||
|
{Class: ClassPaladin, Level: 3},
|
||||||
|
{Class: ClassMage, Level: 4},
|
||||||
|
{Class: ClassMage, Level: 8, Subclass: SubclassEvocation},
|
||||||
|
{Class: ClassFighter, Level: 12, Subclass: SubclassChampion},
|
||||||
|
{Class: ClassRogue, Level: 20, Subclass: SubclassArcaneTrickster},
|
||||||
|
{Class: ClassCleric, Level: 20, Subclass: SubclassLifeDomain},
|
||||||
|
}
|
||||||
|
// entryHP 1.0 is the control arm: it should reproduce the M0 table through the
|
||||||
|
// real path. The wounded arms are the finding.
|
||||||
|
entryHPs := []float64{1.0, 0.7, 0.4}
|
||||||
|
|
||||||
|
seq := 0
|
||||||
|
fmt.Printf("\n%-26s %-6s %-7s %-6s %8s %10s\n",
|
||||||
|
"profile", "tier", "entryHP", "wards", "survive%", "avgEndHP%")
|
||||||
|
for _, prof := range profiles {
|
||||||
|
for _, tier := range mischiefTiers {
|
||||||
|
for _, entry := range entryHPs {
|
||||||
|
// The ward arm only where a ward could decide anything: the tiers the
|
||||||
|
// M0 sweep did not already call theatre.
|
||||||
|
wardArms := []int{0}
|
||||||
|
if tier.Key == "elite" || tier.Key == "boss" {
|
||||||
|
wardArms = []int{0, mischiefBlessingCap}
|
||||||
|
}
|
||||||
|
for _, wards := range wardArms {
|
||||||
|
wins, hpSum := 0, 0.0
|
||||||
|
for i := 0; i < trials; i++ {
|
||||||
|
seq++
|
||||||
|
ok, hpPct := mischiefDeliveryTrial(t, p, prof, tier.Key, entry, wards, seq, rng)
|
||||||
|
if ok {
|
||||||
|
wins++
|
||||||
|
hpSum += hpPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avgHP := 0.0
|
||||||
|
if wins > 0 {
|
||||||
|
avgHP = hpSum / float64(wins) * 100
|
||||||
|
}
|
||||||
|
fmt.Printf("%-26s %-6s %6.0f%% %6d %7.1f%% %9.1f%%\n",
|
||||||
|
fmt.Sprintf("%s L%d %s", prof.Class, prof.Level, prof.Subclass),
|
||||||
|
tier.Key, entry*100, wards, float64(wins)/float64(trials)*100, avgHP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
internal/plugin/mischief_pricing_sweep_test.go
Normal file
85
internal/plugin/mischief_pricing_sweep_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// M0 pricing sweep for gogobee_mischief_plan.md — single-fight survival per
|
||||||
|
// mischief tier, measured against the class-balance harness builds. Skip-gated:
|
||||||
|
// it is a pricing instrument, not a regression test.
|
||||||
|
//
|
||||||
|
// It drives the SAME production selection code a live delivery does
|
||||||
|
// (mischiefBracketZone + mischiefMonsters), so the fee table can't silently
|
||||||
|
// drift away from the fight it was priced against. What it does NOT share is the
|
||||||
|
// delivery path itself: this measures a full-HP build in a single chain, which
|
||||||
|
// is optimistic — a real target is wounded mid-run, and may have a party. The
|
||||||
|
// plan's M1 close-out item is to re-run this through runMischiefInterrupt.
|
||||||
|
//
|
||||||
|
// Run: MISCHIEF_SWEEP=1 go test ./internal/plugin -run TestMischiefPricingSweep -v
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// one trial: full-HP build fights the tier's chain; survival = won every fight.
|
||||||
|
func mischiefTrial(p classBalanceProfile, tierKey string, rng *rand.Rand) (survived bool, endHPPct float64) {
|
||||||
|
c := buildHarnessCharacter(p)
|
||||||
|
zone := mischiefBracketZone(p.Level)
|
||||||
|
chain, ambush := mischiefMonsters(tierKey, zone, rng)
|
||||||
|
hp := c.HPMax
|
||||||
|
for i, m := range chain {
|
||||||
|
if ambush && i == 0 {
|
||||||
|
hp -= clampSurpriseNick(surpriseRoundNick(m, int(zone.Tier)), hp, c.HPMax)
|
||||||
|
}
|
||||||
|
player := buildHarnessPlayer(c)
|
||||||
|
if hp < c.HPMax {
|
||||||
|
player.Stats.StartHP = hp
|
||||||
|
}
|
||||||
|
enemy := buildHarnessZoneEnemy(m, int(zone.Tier))
|
||||||
|
if spell, slot, ok := pickBestDamageSpell(c); ok {
|
||||||
|
applyHarnessSpellCast(c, spell, slot, &player.Stats, &player.Mods, &enemy.Stats)
|
||||||
|
}
|
||||||
|
res := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||||
|
if !res.PlayerWon {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
hp = res.PlayerEndHP
|
||||||
|
}
|
||||||
|
return true, float64(hp) / float64(c.HPMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefPricingSweep(t *testing.T) {
|
||||||
|
if os.Getenv("MISCHIEF_SWEEP") == "" {
|
||||||
|
t.Skip("set MISCHIEF_SWEEP=1 to run")
|
||||||
|
}
|
||||||
|
const trials = 2000
|
||||||
|
rng := rand.New(rand.NewPCG(20260713, 0x6D69736368696566))
|
||||||
|
profiles := []classBalanceProfile{
|
||||||
|
{Class: ClassPaladin, Level: 1},
|
||||||
|
{Class: ClassMage, Level: 4},
|
||||||
|
{Class: ClassMage, Level: 8, Subclass: SubclassEvocation},
|
||||||
|
{Class: ClassFighter, Level: 12, Subclass: SubclassChampion},
|
||||||
|
{Class: ClassRogue, Level: 20, Subclass: SubclassArcaneTrickster},
|
||||||
|
{Class: ClassCleric, Level: 20, Subclass: SubclassLifeDomain},
|
||||||
|
}
|
||||||
|
fmt.Printf("%-28s %-8s %8s %10s\n", "profile", "tier", "survive%", "avgEndHP%")
|
||||||
|
for _, p := range profiles {
|
||||||
|
for _, tier := range mischiefTiers {
|
||||||
|
wins := 0
|
||||||
|
hpSum := 0.0
|
||||||
|
for i := 0; i < trials; i++ {
|
||||||
|
ok, hpPct := mischiefTrial(p, tier.Key, rng)
|
||||||
|
if ok {
|
||||||
|
wins++
|
||||||
|
hpSum += hpPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avgHP := 0.0
|
||||||
|
if wins > 0 {
|
||||||
|
avgHP = hpSum / float64(wins) * 100
|
||||||
|
}
|
||||||
|
fmt.Printf("%-28s %-8s %7.1f%% %9.1f%%\n",
|
||||||
|
fmt.Sprintf("%s L%d %s", p.Class, p.Level, p.Subclass), tier.Key,
|
||||||
|
float64(wins)/trials*100, avgHP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
338
internal/plugin/pete.go
Normal file
338
internal/plugin/pete.go
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
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 "zone_first"; later clears are a
|
||||||
|
// "zone_clear". The event_type and the GUID prefix track which, so Pete
|
||||||
|
// templates and labels a repeat as a repeat, not a first-ever. Character name
|
||||||
|
// only; no-op unless the seam is enabled.
|
||||||
|
//
|
||||||
|
// BULLETIN either way: TwinBee announces the clear in the room as it happens,
|
||||||
|
// and priority tier is what makes Pete post a live beat — so a priority
|
||||||
|
// zone_first would just echo TwinBee back at the same people. Bulletin still
|
||||||
|
// gets the story onto the site and into the daily digest, where it reads as a
|
||||||
|
// roundup rather than a repeat.
|
||||||
|
// emitBoredomDeparture announces that an adventurer got restless and let itself
|
||||||
|
// out. The one thing gogobee never used to tell Pete was that an expedition
|
||||||
|
// *started* — every dispatch was an outcome — which is why the two live boredom
|
||||||
|
// runs produced no news at all.
|
||||||
|
//
|
||||||
|
// The event_type must be one Pete already knows: an unknown type is a 400, which
|
||||||
|
// retries and then parks the bulletin forever. Deploy Pete first.
|
||||||
|
func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) {
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := charName(userID)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ts := nowUnix()
|
||||||
|
disc := fmt.Sprintf("departure:%s:%d", zone.ID, ts)
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("departure:%s:%s:%d", eventToken(userID, disc), zone.ID, ts),
|
||||||
|
EventType: "departure",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: name,
|
||||||
|
Zone: zone.Display,
|
||||||
|
Level: level,
|
||||||
|
Outcome: "departed",
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, userID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := "zone_clear"
|
||||||
|
if claimRealmFirst("zone", string(exp.ZoneID)) {
|
||||||
|
eventType = "zone_first"
|
||||||
|
}
|
||||||
|
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: "bulletin",
|
||||||
|
Subject: name,
|
||||||
|
Zone: zone.Display,
|
||||||
|
Region: region,
|
||||||
|
Boss: zone.Boss.Name,
|
||||||
|
Level: lvl,
|
||||||
|
Outcome: "cleared",
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, userID, "")
|
||||||
|
}
|
||||||
165
internal/plugin/pete_games.go
Normal file
165
internal/plugin/pete_games.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The casino's money loop.
|
||||||
|
//
|
||||||
|
// Pete runs the games and holds the chips; we hold the euros and are the only
|
||||||
|
// one who can move them. A player who buys in or cashes out on games.parodia.dev
|
||||||
|
// opens an escrow row over there, and this loop is what turns it into a real
|
||||||
|
// balance change over here.
|
||||||
|
//
|
||||||
|
// It has to be a poll because Pete cannot call us — there is no inbound API on
|
||||||
|
// this box and there isn't going to be one. That constraint is what shaped the
|
||||||
|
// whole design: money crosses the border twice per *session*, not twice per
|
||||||
|
// hand, so a few seconds of poll latency lands on "buying chips…" and never on
|
||||||
|
// "deal me in".
|
||||||
|
//
|
||||||
|
// Every step is idempotent on the escrow guid, because every step can be
|
||||||
|
// interrupted in the worst possible place. If we die after DebitIdem and before
|
||||||
|
// the verdict is queued, Pete re-offers the row, we claim it again, DebitIdem
|
||||||
|
// replays as a no-op and reports the same answer, and the verdict goes out. The
|
||||||
|
// player is charged once. That is the only property here that really matters.
|
||||||
|
const (
|
||||||
|
// escrowPollInterval — a player is watching a spinner, so this is seconds,
|
||||||
|
// not the 30s the mischief seam gets away with.
|
||||||
|
escrowPollInterval = 3 * time.Second
|
||||||
|
|
||||||
|
// escrowPollTimeout — one full poll-claim-settle pass. Generous: the
|
||||||
|
// alternative to finishing is a claimed row nobody answers.
|
||||||
|
escrowPollTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
reasonInsufficientFunds = "insufficient_funds"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartPeteEscrowLoop runs the border forever. Safe to call when the Pete seam
|
||||||
|
// is off — it simply never starts.
|
||||||
|
func StartPeteEscrowLoop(ctx context.Context, euro *EuroPlugin) {
|
||||||
|
if !peteclient.Enabled() || euro == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("pete games: escrow loop started", "interval", escrowPollInterval)
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(escrowPollInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
pollEscrowOnce(ctx, euro)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escrowPollOK tracks the poll's health so we log transitions and not a line
|
||||||
|
// every three seconds. Pete being down is normal during its redeploys and is not
|
||||||
|
// worth shouting about; Pete being down for an hour is.
|
||||||
|
var escrowPollOK = true
|
||||||
|
|
||||||
|
func pollEscrowOnce(ctx context.Context, euro *EuroPlugin) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, escrowPollTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pending, err := peteclient.PendingEscrow(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if escrowPollOK {
|
||||||
|
slog.Warn("pete games: escrow poll failed — buy-ins and cash-outs are stalled", "err", err)
|
||||||
|
escrowPollOK = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !escrowPollOK {
|
||||||
|
slog.Info("pete games: escrow poll recovered")
|
||||||
|
escrowPollOK = true
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range pending {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settleEscrow(ctx, euro, e)
|
||||||
|
}
|
||||||
|
// The verdicts are on the durable queue now. Send them at once rather than
|
||||||
|
// waiting up to a sender tick — there is a person at the other end of this.
|
||||||
|
peteclient.Flush(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleEscrow moves the euros for one crossing and queues the answer.
|
||||||
|
func settleEscrow(ctx context.Context, euro *EuroPlugin, e peteclient.Escrow) {
|
||||||
|
// Claim first. The claim is what fixes the amount and the player: the poll's
|
||||||
|
// copy of the row is already a few milliseconds stale, and this is money.
|
||||||
|
claimed, err := peteclient.ClaimEscrow(ctx, e.GUID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pete games: claim failed, will retry", "guid", e.GUID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Pete has already decided this one — a stale re-offer that raced our own
|
||||||
|
// earlier verdict. Nothing to do, and nothing went wrong.
|
||||||
|
if claimed.State != "claimed" {
|
||||||
|
slog.Debug("pete games: escrow already decided", "guid", e.GUID, "state", claimed.State)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claimed.Amount <= 0 {
|
||||||
|
slog.Error("pete games: escrow with a non-positive amount, refusing",
|
||||||
|
"guid", claimed.GUID, "amount", claimed.Amount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := id.UserID(claimed.MatrixUser)
|
||||||
|
amount := float64(claimed.Amount)
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
var balance float64
|
||||||
|
switch claimed.Kind {
|
||||||
|
case "buyin":
|
||||||
|
ok, balance, err = euro.DebitIdem(user, amount, "games_buyin", claimed.GUID)
|
||||||
|
case "cashout":
|
||||||
|
ok, balance, err = euro.CreditIdem(user, amount, "games_cashout", claimed.GUID)
|
||||||
|
default:
|
||||||
|
// Not a transient failure and not something a retry fixes. Say no, so the
|
||||||
|
// row reaches a terminal state on Pete instead of being re-offered forever.
|
||||||
|
slog.Error("pete games: unknown escrow kind", "guid", claimed.GUID, "kind", claimed.Kind)
|
||||||
|
peteclient.EmitEscrowVerdict(peteclient.EscrowVerdict{
|
||||||
|
GUID: claimed.GUID, OK: false, Reason: "unknown_kind",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// The money did not move and we don't know that it didn't — so say
|
||||||
|
// nothing. Pete re-offers the row once the claim goes stale, and the guid
|
||||||
|
// makes the retry safe whichever way this actually landed.
|
||||||
|
slog.Error("pete games: euro move failed, leaving the row for a retry",
|
||||||
|
"guid", claimed.GUID, "kind", claimed.Kind, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := ""
|
||||||
|
if !ok {
|
||||||
|
// The only way DebitIdem says no: the player cannot cover it. A cash-out
|
||||||
|
// has no floor to breach, so this is always a buy-in.
|
||||||
|
reason = reasonInsufficientFunds
|
||||||
|
}
|
||||||
|
|
||||||
|
peteclient.EmitEscrowVerdict(peteclient.EscrowVerdict{
|
||||||
|
GUID: claimed.GUID,
|
||||||
|
OK: ok,
|
||||||
|
Reason: reason,
|
||||||
|
BalanceAfter: balance,
|
||||||
|
})
|
||||||
|
slog.Info("pete games: escrow moved", "guid", claimed.GUID, "user", claimed.MatrixUser,
|
||||||
|
"kind", claimed.Kind, "amount", claimed.Amount, "ok", ok, "balance_after", balance)
|
||||||
|
}
|
||||||
222
internal/plugin/pete_games_test.go
Normal file
222
internal/plugin/pete_games_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePete is Pete's half of the escrow wire: it offers rows, lets us claim
|
||||||
|
// them, and records the verdicts we push back. Enough to drive the loop end to
|
||||||
|
// end without either real box.
|
||||||
|
type fakePete struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pending []peteclient.Escrow
|
||||||
|
claimed map[string]bool
|
||||||
|
verdicts []peteclient.EscrowVerdict
|
||||||
|
srv *httptest.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakePete(t *testing.T, token string, rows ...peteclient.Escrow) *fakePete {
|
||||||
|
t.Helper()
|
||||||
|
f := &fakePete{pending: rows, claimed: map[string]bool{}}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
auth := func(r *http.Request) bool { return r.Header.Get("Authorization") == "Bearer "+token }
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/games/escrow/pending", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !auth(r) {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
_ = json.NewEncoder(w).Encode(f.pending)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/games/escrow/claim", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !auth(r) {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct{ GUID string }
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, e := range f.pending {
|
||||||
|
if e.GUID != req.GUID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.claimed[e.GUID] = true
|
||||||
|
e.State = "claimed"
|
||||||
|
_ = json.NewEncoder(w).Encode(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(404)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/games/escrow/settled", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !auth(r) {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v peteclient.EscrowVerdict
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||||
|
w.WriteHeader(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.verdicts = append(f.verdicts, v)
|
||||||
|
w.WriteHeader(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
f.srv = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(f.srv.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// wire points the peteclient singleton at the fake and turns the seam on.
|
||||||
|
func (f *fakePete) wire(t *testing.T, token string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("PETE_INGEST_URL", f.srv.URL)
|
||||||
|
t.Setenv("PETE_INGEST_TOKEN", token)
|
||||||
|
t.Setenv("FEATURE_PETE_NEWS", "true")
|
||||||
|
peteclient.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePete) got() []peteclient.EscrowVerdict {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]peteclient.EscrowVerdict(nil), f.verdicts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowLoopBuyInDebitsOnceAndReportsIt is the happy path across the border:
|
||||||
|
// Pete offers a buy-in, we take the euros, and the verdict lands back on Pete
|
||||||
|
// carrying the balance the player now has.
|
||||||
|
func TestEscrowLoopBuyInDebitsOnceAndReportsIt(t *testing.T) {
|
||||||
|
euro := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@reala:parodia.dev")
|
||||||
|
seedBalance(t, euro, user, 1000)
|
||||||
|
|
||||||
|
pete := newFakePete(t, "tok", peteclient.Escrow{
|
||||||
|
GUID: "esc-1", MatrixUser: string(user), Kind: "buyin", Amount: 400, State: "requested",
|
||||||
|
})
|
||||||
|
pete.wire(t, "tok")
|
||||||
|
|
||||||
|
pollEscrowOnce(context.Background(), euro)
|
||||||
|
|
||||||
|
if got := euro.GetBalance(user); got != 600 {
|
||||||
|
t.Fatalf("balance = %v, want 600 (1000 less a 400 buy-in)", got)
|
||||||
|
}
|
||||||
|
v := pete.got()
|
||||||
|
if len(v) != 1 || !v[0].OK || v[0].GUID != "esc-1" {
|
||||||
|
t.Fatalf("verdicts = %+v, want one ok verdict for esc-1", v)
|
||||||
|
}
|
||||||
|
if v[0].BalanceAfter != 600 {
|
||||||
|
t.Fatalf("balance_after = %v, want 600", v[0].BalanceAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowLoopReofferedRowDebitsOnce is the failure this whole design is built
|
||||||
|
// around. We move the euros, then die before the verdict is delivered. Pete
|
||||||
|
// re-offers the row. The player must not pay twice.
|
||||||
|
func TestEscrowLoopReofferedRowDebitsOnce(t *testing.T) {
|
||||||
|
euro := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@twice:parodia.dev")
|
||||||
|
seedBalance(t, euro, user, 1000)
|
||||||
|
|
||||||
|
pete := newFakePete(t, "tok", peteclient.Escrow{
|
||||||
|
GUID: "esc-dup", MatrixUser: string(user), Kind: "buyin", Amount: 250, State: "requested",
|
||||||
|
})
|
||||||
|
pete.wire(t, "tok")
|
||||||
|
|
||||||
|
// Three passes over a row Pete keeps offering, as it would after a crash.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
pollEscrowOnce(context.Background(), euro)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := euro.GetBalance(user); got != 750 {
|
||||||
|
t.Fatalf("balance = %v, want 750 — the re-offered row debited more than once", got)
|
||||||
|
}
|
||||||
|
// The verdict is queued under the escrow guid, so it is enqueued once no
|
||||||
|
// matter how many times we decide it. Pete's settle is idempotent anyway,
|
||||||
|
// but the queue is the first line of that defence.
|
||||||
|
if v := pete.got(); len(v) != 1 {
|
||||||
|
t.Fatalf("delivered %d verdicts for one row, want 1: %+v", len(v), v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowLoopBrokePlayerIsRejectedCleanly — the debit is refused. Nothing may
|
||||||
|
// move, and Pete has to be told why, because the player is staring at a spinner
|
||||||
|
// that needs to become a sentence.
|
||||||
|
func TestEscrowLoopBrokePlayerIsRejectedCleanly(t *testing.T) {
|
||||||
|
euro := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@broke:parodia.dev")
|
||||||
|
seedBalance(t, euro, user, 10)
|
||||||
|
|
||||||
|
pete := newFakePete(t, "tok", peteclient.Escrow{
|
||||||
|
GUID: "esc-broke", MatrixUser: string(user), Kind: "buyin", Amount: 5000, State: "requested",
|
||||||
|
})
|
||||||
|
pete.wire(t, "tok")
|
||||||
|
|
||||||
|
pollEscrowOnce(context.Background(), euro)
|
||||||
|
|
||||||
|
if got := euro.GetBalance(user); got != 10 {
|
||||||
|
t.Fatalf("balance = %v, want 10 untouched", got)
|
||||||
|
}
|
||||||
|
v := pete.got()
|
||||||
|
if len(v) != 1 || v[0].OK || v[0].Reason != reasonInsufficientFunds {
|
||||||
|
t.Fatalf("verdicts = %+v, want one rejection carrying insufficient_funds", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowLoopCashOutCredits — the way out of the casino.
|
||||||
|
func TestEscrowLoopCashOutCredits(t *testing.T) {
|
||||||
|
euro := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@winner:parodia.dev")
|
||||||
|
seedBalance(t, euro, user, 100)
|
||||||
|
|
||||||
|
pete := newFakePete(t, "tok", peteclient.Escrow{
|
||||||
|
GUID: "esc-out", MatrixUser: string(user), Kind: "cashout", Amount: 900, State: "requested",
|
||||||
|
})
|
||||||
|
pete.wire(t, "tok")
|
||||||
|
|
||||||
|
pollEscrowOnce(context.Background(), euro)
|
||||||
|
|
||||||
|
if got := euro.GetBalance(user); got != 1000 {
|
||||||
|
t.Fatalf("balance = %v, want 1000", got)
|
||||||
|
}
|
||||||
|
if v := pete.got(); len(v) != 1 || !v[0].OK || v[0].BalanceAfter != 1000 {
|
||||||
|
t.Fatalf("verdicts = %+v, want one ok cash-out at 1000", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowLoopUnknownKindIsRefusedNotRetried — a row we can't interpret. A
|
||||||
|
// silent skip would leave it re-offered forever, so say no and let it reach a
|
||||||
|
// terminal state on Pete.
|
||||||
|
func TestEscrowLoopUnknownKindIsRefusedNotRetried(t *testing.T) {
|
||||||
|
euro := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@weird:parodia.dev")
|
||||||
|
seedBalance(t, euro, user, 500)
|
||||||
|
|
||||||
|
pete := newFakePete(t, "tok", peteclient.Escrow{
|
||||||
|
GUID: "esc-weird", MatrixUser: string(user), Kind: "tribute", Amount: 100, State: "requested",
|
||||||
|
})
|
||||||
|
pete.wire(t, "tok")
|
||||||
|
|
||||||
|
pollEscrowOnce(context.Background(), euro)
|
||||||
|
|
||||||
|
if got := euro.GetBalance(user); got != 500 {
|
||||||
|
t.Fatalf("balance = %v, want 500 untouched", got)
|
||||||
|
}
|
||||||
|
v := pete.got()
|
||||||
|
if len(v) != 1 || v[0].OK || v[0].Reason != "unknown_kind" {
|
||||||
|
t.Fatalf("verdicts = %+v, want one refusal", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
internal/plugin/pete_roster.go
Normal file
177
internal/plugin/pete_roster.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The live adventurer board pushed to Pete (gogobee_boredom_plan.md's sibling —
|
||||||
|
// see the roster section of the Pete plan).
|
||||||
|
//
|
||||||
|
// Everything else we send Pete is an accomplishment: a death, a clear, a
|
||||||
|
// milestone. Those are clippings — they read as archive the moment they land, no
|
||||||
|
// matter how fast we deliver them. The board is the other kind of thing: state
|
||||||
|
// that is *currently true*, which is the only thing that can make a page feel
|
||||||
|
// alive. So it is a snapshot, pushed whole, replacing whatever Pete had.
|
||||||
|
//
|
||||||
|
// It is also, by design, a target list. The plan is to let people who aren't
|
||||||
|
// even playing hire assassins and mobs against adventurers who are out in the
|
||||||
|
// world right now — so the board carries a stable per-player token and real zone
|
||||||
|
// depth, not just a pretty display string, and it shows the zone *live* while
|
||||||
|
// they're still in it.
|
||||||
|
const (
|
||||||
|
// rosterTickInterval — how often we push. Pete's staleness window is several
|
||||||
|
// times this, so a missed push or two is invisible; a real outage isn't.
|
||||||
|
rosterTickInterval = 2 * time.Minute
|
||||||
|
|
||||||
|
// rosterPushTimeout — the push is dropped on failure, never retried (a stale
|
||||||
|
// snapshot is a lie, and the next tick carries the truth), so it must not be
|
||||||
|
// able to pile up.
|
||||||
|
rosterPushTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// peteRosterTicker pushes the board to Pete forever.
|
||||||
|
func (p *AdventurePlugin) peteRosterTicker() {
|
||||||
|
if !peteclient.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(rosterTickInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
if !newsEmissionOn() {
|
||||||
|
continue // master switch off: the board goes stale on Pete and says so
|
||||||
|
}
|
||||||
|
p.pushRoster()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterPushOK tracks the last push's outcome so we can log the transitions and
|
||||||
|
// nothing else. A push every 2 minutes forever is far too noisy to log at INFO,
|
||||||
|
// but total silence is worse: a ticker that is succeeding quietly looks exactly
|
||||||
|
// like one that never started, and that ambiguity already cost an operator a
|
||||||
|
// wrong-turn debug during the first deploy. So: say something the first time it
|
||||||
|
// works, say something when it breaks, say something when it recovers.
|
||||||
|
var rosterPushOK bool
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) pushRoster() {
|
||||||
|
snap, err := buildRosterSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("roster: build snapshot failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := peteclient.PushRoster(ctx, snap); err != nil {
|
||||||
|
// The failure itself is not alarming — the next tick retries by simply
|
||||||
|
// being a fresher snapshot, and if we stay down Pete's board correctly
|
||||||
|
// stops claiming to be live. Only the *transition* is worth a line.
|
||||||
|
if rosterPushOK {
|
||||||
|
slog.Warn("roster: push failed, board will go stale on Pete", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("roster: push failed, dropping snapshot", "err", err)
|
||||||
|
}
|
||||||
|
rosterPushOK = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rosterPushOK {
|
||||||
|
slog.Info("roster: board accepted by Pete — live adventurer board is publishing",
|
||||||
|
"adventurers", len(snap.Adventurers))
|
||||||
|
rosterPushOK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRosterSnapshot assembles the complete board.
|
||||||
|
//
|
||||||
|
// Complete is the contract: Pete *replaces* its board with this, so anyone we
|
||||||
|
// omit drops off the public page. That is exactly how the opt-out is enforced —
|
||||||
|
// an opted-out player is simply never in the payload, rather than being sent and
|
||||||
|
// anonymized. A standing row showing class + level + zone is trivially
|
||||||
|
// re-identifiable (there is one level-14 cleric), so "an adventurer" would have
|
||||||
|
// been a fig leaf; absence is the only honest option.
|
||||||
|
func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
|
||||||
|
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix()}
|
||||||
|
|
||||||
|
// Both DATETIME columns selected raw and folded in Go — NOT COALESCE()'d in
|
||||||
|
// SQL. modernc.org/sqlite rebuilds a time.Time from the column's *declared*
|
||||||
|
// type, and COALESCE() erases that affinity: the value comes back a string
|
||||||
|
// and the Scan fails. Same trap playerIsIdle documents.
|
||||||
|
rows, err := db.Get().Query(`
|
||||||
|
SELECT user_id, last_player_action_at, created_at
|
||||||
|
FROM player_meta
|
||||||
|
WHERE alive = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type player struct {
|
||||||
|
uid id.UserID
|
||||||
|
lastAction *time.Time
|
||||||
|
}
|
||||||
|
var players []player
|
||||||
|
for rows.Next() {
|
||||||
|
var uid string
|
||||||
|
var lastAction, created *time.Time
|
||||||
|
if err := rows.Scan(&uid, &lastAction, &created); err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
if lastAction == nil {
|
||||||
|
lastAction = created
|
||||||
|
}
|
||||||
|
players = append(players, player{id.UserID(uid), lastAction})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pl := range players {
|
||||||
|
if isNewsOptedOut(pl.uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, err := LoadDnDCharacter(pl.uid)
|
||||||
|
if err != nil || c == nil || c.PendingSetup {
|
||||||
|
continue // no character to show; a half-made one has no name yet
|
||||||
|
}
|
||||||
|
name := charName(pl.uid)
|
||||||
|
if name == "" {
|
||||||
|
continue // never fall back to a Matrix handle on a public page
|
||||||
|
}
|
||||||
|
|
||||||
|
e := peteclient.RosterEntry{
|
||||||
|
// Stable per-player board token: salted, so it can't be recomputed
|
||||||
|
// from the handle, and distinct from every event token, so the board
|
||||||
|
// doesn't become the key that links a player's dispatches together.
|
||||||
|
Token: eventToken(pl.uid, "roster"),
|
||||||
|
Name: name,
|
||||||
|
Level: c.Level,
|
||||||
|
ClassRace: classRaceLabel(c),
|
||||||
|
Status: "idle",
|
||||||
|
}
|
||||||
|
|
||||||
|
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
|
||||||
|
zone := zoneOrFallback(exp.ZoneID)
|
||||||
|
e.Status = "expedition"
|
||||||
|
e.Zone = zone.Display
|
||||||
|
e.Day = exp.CurrentDay
|
||||||
|
if IsMultiRegionZone(exp.ZoneID) {
|
||||||
|
if r, ok := CurrentRegion(exp); ok {
|
||||||
|
e.Region = r.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if pl.lastAction != nil {
|
||||||
|
if h := int(now.Sub(*pl.lastAction).Hours()); h > 0 {
|
||||||
|
e.IdleHours = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snap.Adventurers = append(snap.Adventurers, e)
|
||||||
|
}
|
||||||
|
return snap, nil
|
||||||
|
}
|
||||||
127
internal/plugin/pete_roster_test.go
Normal file
127
internal/plugin/pete_roster_test.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedRosterPlayer puts a real, playable character on the board: a player_meta
|
||||||
|
// row (which carries the idle clock and the display name) plus a confirmed
|
||||||
|
// dnd_character. Real rows on purpose — the snapshot query reads two DATETIME
|
||||||
|
// columns, and the modernc affinity trap only fires against actual stored
|
||||||
|
// values, never against a hand-built struct.
|
||||||
|
func seedRosterPlayer(t *testing.T, uid id.UserID, name string, created, lastAction *time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?)`,
|
||||||
|
string(uid), name, created, lastAction); err != nil {
|
||||||
|
t.Fatalf("seed player_meta: %v", err)
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(&DnDCharacter{
|
||||||
|
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||||
|
STR: 15, DEX: 14, CON: 13, INT: 12, WIS: 10, CHA: 8,
|
||||||
|
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
|
||||||
|
PendingSetup: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed character: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRosterSnapshotReadsTheClock is the scan-affinity guard. The snapshot
|
||||||
|
// selects last_player_action_at and created_at as declared DATETIME columns and
|
||||||
|
// folds them in Go; a COALESCE() in the SQL would erase the affinity, the Scan
|
||||||
|
// would fail, and buildRosterSnapshot would return an error — publishing an
|
||||||
|
// empty board (every adventurer vanishes from the public page) rather than
|
||||||
|
// anything obviously broken.
|
||||||
|
func TestRosterSnapshotReadsTheClock(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-50 * time.Hour)
|
||||||
|
recent := now.Add(-3 * time.Hour)
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@quiet:test", "Quack", &old, &old)
|
||||||
|
seedRosterPlayer(t, "@recent:test", "Josie", &old, &recent)
|
||||||
|
// Never acted at all: the fold falls through to created_at.
|
||||||
|
seedRosterPlayer(t, "@never:test", "Camcast", &old, nil)
|
||||||
|
|
||||||
|
snap, err := buildRosterSnapshot(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.Adventurers) != 3 {
|
||||||
|
t.Fatalf("board has %d adventurers, want 3", len(snap.Adventurers))
|
||||||
|
}
|
||||||
|
if snap.SnapshotAt != now.Unix() {
|
||||||
|
t.Errorf("snapshot_at = %d, want %d", snap.SnapshotAt, now.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
byName := map[string]int{}
|
||||||
|
for _, a := range snap.Adventurers {
|
||||||
|
byName[a.Name] = a.IdleHours
|
||||||
|
if a.Status != "idle" {
|
||||||
|
t.Errorf("%s status = %q, want idle (nobody is on an expedition)", a.Name, a.Status)
|
||||||
|
}
|
||||||
|
if a.Token == "" {
|
||||||
|
t.Errorf("%s has no board token", a.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := byName["Quack"]; got != 50 {
|
||||||
|
t.Errorf("Quack idle hours = %d, want 50 — the clock didn't survive the scan", got)
|
||||||
|
}
|
||||||
|
if got := byName["Josie"]; got != 3 {
|
||||||
|
t.Errorf("Josie idle hours = %d, want 3", got)
|
||||||
|
}
|
||||||
|
if got := byName["Camcast"]; got != 50 {
|
||||||
|
t.Errorf("Camcast idle hours = %d, want 50 (fell through to created_at)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRosterOmitsOptedOut is the privacy contract. Pete *replaces* its whole
|
||||||
|
// board with this payload, so omission is what enforces the opt-out. Anonymizing
|
||||||
|
// instead would be a fig leaf: a standing row with class + level + zone names the
|
||||||
|
// player anyway.
|
||||||
|
func TestRosterOmitsOptedOut(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-30 * time.Hour)
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@shown:test", "Josie", &old, &old)
|
||||||
|
seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old)
|
||||||
|
setNewsOptout("@hidden:test", true)
|
||||||
|
|
||||||
|
snap, err := buildRosterSnapshot(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.Adventurers) != 1 {
|
||||||
|
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
|
||||||
|
}
|
||||||
|
if snap.Adventurers[0].Name != "Quack" && snap.Adventurers[0].Name != "Josie" {
|
||||||
|
t.Fatalf("unexpected adventurer %q", snap.Adventurers[0].Name)
|
||||||
|
}
|
||||||
|
if snap.Adventurers[0].Name == "Quack" {
|
||||||
|
t.Error("an opted-out player is on the public board")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRosterTokenIsNotAnEventToken: the board token must not be one of the
|
||||||
|
// player's event tokens, or a standing row would become the key that links all
|
||||||
|
// their dispatches back together — the exact unlinkability eventToken exists to
|
||||||
|
// provide.
|
||||||
|
func TestRosterTokenIsNotAnEventToken(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
uid := id.UserID("@josie:test")
|
||||||
|
|
||||||
|
board := eventToken(uid, "roster")
|
||||||
|
if board == eventToken(uid, "arrival") || board == eventToken(uid, "death:crypt:1") {
|
||||||
|
t.Error("board token collides with an event token — the board would deanonymize the feed")
|
||||||
|
}
|
||||||
|
if board != eventToken(uid, "roster") {
|
||||||
|
t.Error("board token not stable — the row would churn identity every snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
1908
internal/plugin/testdata/party_characterization.golden
vendored
Normal file
1908
internal/plugin/testdata/party_characterization.golden
vendored
Normal file
File diff suppressed because it is too large
Load Diff
151
internal/plugin/zone_combat_autocast.go
Normal file
151
internal/plugin/zone_combat_autocast.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// §6 — a caster casts in an auto-resolved fight.
|
||||||
|
//
|
||||||
|
// An ordinary room fight is runZoneCombatRoster → SimulateCombat on an 8-round
|
||||||
|
// clock: one breath, no turn engine, no action picker. The only spell that has
|
||||||
|
// ever landed there is a PendingCast the player queued BY HAND with `!cast`
|
||||||
|
// before walking in. Under autopilot nobody queues one — so for every room of
|
||||||
|
// every expedition, a caster fought with a weapon and nothing else.
|
||||||
|
//
|
||||||
|
// A cleric is the extreme case: no Extra Attack, a mace, and its whole kit
|
||||||
|
// unusable. Measured on the room path at L10, a wounded cleric loses 8% of
|
||||||
|
// ORDINARY room fights (a fighter loses 0.0%), and a room loss ends the entire
|
||||||
|
// expedition — "the fight drags on, X outlasts you, you retreat". That is why
|
||||||
|
// cleric fled 167 of 500 runs in the corpus while fighter, ranger and paladin
|
||||||
|
// fled none, and it is the whole of the "cleric is a weak class" gap.
|
||||||
|
//
|
||||||
|
// The tell: dnd_class_balance.go — the harness the class corpus was tuned on —
|
||||||
|
// ALWAYS hands a caster their best damage spell (pickBestDamageSpell +
|
||||||
|
// applyHarnessSpellCast) before it simulates. So the numbers we balanced against
|
||||||
|
// modelled a caster who casts, and the live room modelled one who does not. This
|
||||||
|
// closes that gap: the character fights with the kit it actually owns.
|
||||||
|
//
|
||||||
|
// It is NOT a new mechanic. A folded-in spell is additive pre-damage, exactly as
|
||||||
|
// a hand-queued PendingCast has always been, which is what keeps this comparable
|
||||||
|
// to the corpus rather than a fresh invention.
|
||||||
|
|
||||||
|
// autoCastForAutoResolve picks the caster's best damage spell, spends the slot,
|
||||||
|
// and folds the damage into the Combatant pair. Reports whether it cast.
|
||||||
|
//
|
||||||
|
// It reads the seat's ACTUAL remaining slots, not the class slot table.
|
||||||
|
// pickBestDamageSpell (the harness one) reads slotsForClassLevel — the
|
||||||
|
// theoretical pool — which is right for a one-fight harness and would be an
|
||||||
|
// infinite spell here: the same "no row to persist onto, so it arrives fresh"
|
||||||
|
// bug that gave the companion an unlimited body and an unlimited slot pool.
|
||||||
|
// A leveled spell cast here costs a real slot and stays spent until a rest.
|
||||||
|
func (p *AdventurePlugin) autoCastForAutoResolve(
|
||||||
|
uid id.UserID,
|
||||||
|
c *DnDCharacter,
|
||||||
|
playerStats *CombatStats,
|
||||||
|
playerMods *CombatModifiers,
|
||||||
|
enemyStats *CombatStats,
|
||||||
|
) bool {
|
||||||
|
// A hand-queued spell wins: the player already chose, applyPendingCast owns
|
||||||
|
// it, and casting a second one would be two spells in one action.
|
||||||
|
if c == nil || c.PendingCast != "" || !isSpellcaster(c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
spell, slot, ok := pickAutoResolveSpell(uid, c)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Leveled spells cost a slot. Spend it BEFORE the damage lands, and bail if
|
||||||
|
// the debit fails — a spell that could not be paid for must not be cast.
|
||||||
|
if slot > 0 {
|
||||||
|
spent, err := consumeSpellSlot(uid, slot)
|
||||||
|
if err != nil || !spent {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyHarnessSpellCast(c, spell, slot, playerStats, playerMods, enemyStats)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomSlotCap is the highest slot level an ordinary room may spend. Cantrips are
|
||||||
|
// free and always available; anything above this is reserved for the elite and
|
||||||
|
// the boss.
|
||||||
|
//
|
||||||
|
// The first cut of §6 had no cap — "never upcast" was supposed to be enough. It
|
||||||
|
// was not, and the sweep said so: room deaths fell for every caster (mage 131 →
|
||||||
|
// 105) while elite and boss deaths exploded (mage 7 → 38 and 19 → 61, sorcerer
|
||||||
|
// 14 → 51 and 15 → 65). Casters were winning the trash rooms and arriving at the
|
||||||
|
// thing that matters with an empty pool. Native-level is not a reserve, because
|
||||||
|
// a mage's native-level spells ARE its boss kit; only a level cap is.
|
||||||
|
//
|
||||||
|
// 2 is where the two demands stop overlapping. Every caster owns a damage spell
|
||||||
|
// at or below it (a cleric's guiding_bolt and inflict_wounds are both 1st), so
|
||||||
|
// the rooms still get a real spell — but a fireball, a spirit_guardians, a
|
||||||
|
// flame_strike, and every slot the turn engine would upcast into stays in the
|
||||||
|
// caster's pocket until there is something worth spending it on.
|
||||||
|
const roomSlotCap = 2
|
||||||
|
|
||||||
|
// pickAutoResolveSpell is the best damage spell this caster can actually cast
|
||||||
|
// right now: known, prepared, on their list, at or under the room slot cap, and
|
||||||
|
// backed by a slot they still hold.
|
||||||
|
//
|
||||||
|
// It casts at the spell's NATIVE level and never upcasts. simPickSpell upcasts
|
||||||
|
// aggressively — correct there, because it runs in the turn engine at an elite
|
||||||
|
// or a boss, which is what the big slots are for. A trash room is not, and a
|
||||||
|
// picker that nukes a goblin with a 5th-level slot would leave the caster
|
||||||
|
// swinging a stick at the thing that actually matters.
|
||||||
|
func pickAutoResolveSpell(uid id.UserID, c *DnDCharacter) (SpellDefinition, int, bool) {
|
||||||
|
known, err := listKnownSpells(uid)
|
||||||
|
if err != nil || len(known) == 0 {
|
||||||
|
return SpellDefinition{}, 0, false
|
||||||
|
}
|
||||||
|
slots, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
return SpellDefinition{}, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var best SpellDefinition
|
||||||
|
bestSlot := 0
|
||||||
|
bestScore := -1.0
|
||||||
|
for _, k := range known {
|
||||||
|
if !k.Prepared {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp, ok := lookupSpell(k.SpellID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch sp.Effect {
|
||||||
|
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto:
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The auto-resolve engine has no reaction window, same as everywhere else.
|
||||||
|
if sp.CastTime == CastReaction {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
onList := false
|
||||||
|
for _, cl := range sp.Classes {
|
||||||
|
if cl == c.Class {
|
||||||
|
onList = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !onList {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A cantrip is free; a leveled spell needs a slot still in hand, and the
|
||||||
|
// room only gets to reach for the small ones.
|
||||||
|
if sp.Level > 0 {
|
||||||
|
if sp.Level > roomSlotCap {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if score := spellExpectedDamage(sp, sp.Level, c.Level); score > bestScore {
|
||||||
|
best, bestSlot, bestScore = sp, sp.Level, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, bestSlot, bestScore >= 0
|
||||||
|
}
|
||||||
254
internal/plugin/zone_combat_autocast_test.go
Normal file
254
internal/plugin/zone_combat_autocast_test.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// §6 — the caster casts in an auto-resolved room fight.
|
||||||
|
//
|
||||||
|
// Before this, the ONLY spell that could land in an ordinary room was one the
|
||||||
|
// player hand-queued with `!cast` before walking in. On autopilot nobody queues
|
||||||
|
// one, so a cleric fought every room of every expedition with a mace, lost room
|
||||||
|
// fights a fighter never loses, and a room loss ends the whole expedition.
|
||||||
|
|
||||||
|
func autocastCleric(t *testing.T, tag string) (id.UserID, *DnDCharacter) {
|
||||||
|
t.Helper()
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
s := &SimRunner{P: p}
|
||||||
|
uid := id.UserID("@autocast-" + tag + ":example.org")
|
||||||
|
c, err := s.BuildCharacter(uid, ClassCleric, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return uid, c
|
||||||
|
}
|
||||||
|
|
||||||
|
func usedSlots(t *testing.T, uid id.UserID) int {
|
||||||
|
t.Helper()
|
||||||
|
slots, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for _, pair := range slots {
|
||||||
|
total += pair[1]
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoCast_CasterCastsAndPaysForIt(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid, c := autocastCleric(t, "pays")
|
||||||
|
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
|
||||||
|
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
t.Fatal("a level-10 cleric with a full slot pool cast nothing")
|
||||||
|
}
|
||||||
|
// It has to be PAID for. pickBestDamageSpell reads the theoretical class slot
|
||||||
|
// table; if we had reused it here the spell would be infinite — the same
|
||||||
|
// free-lunch bug that gave the companion an unlimited body. The slot is spent
|
||||||
|
// whether or not the spell connects, exactly as it is at a real table.
|
||||||
|
if used := usedSlots(t, uid); used != 1 {
|
||||||
|
t.Fatalf("slots used = %d, want exactly 1 — the spell must cost a real slot", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slot has to buy damage. Not on any GIVEN cast — under the room slot cap a
|
||||||
|
// cleric reaches for inflict_wounds, which is an attack-roll spell and is
|
||||||
|
// allowed to miss — but a caster who spends its pool and never scratches
|
||||||
|
// anything would be strictly worse than swinging the mace.
|
||||||
|
func TestAutoCast_SpellActuallyDealsDamage(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid, c := autocastCleric(t, "damage")
|
||||||
|
|
||||||
|
landed := 0
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 1000, AC: 10}
|
||||||
|
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mods.SpellPreDamage > 0 || enemy.MaxHP < 1000 {
|
||||||
|
landed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if landed == 0 {
|
||||||
|
t.Fatal("40 casts against AC 10 and not one dealt a point of damage — " +
|
||||||
|
"the caster is spending slots on air")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is finite: cast far more times than the caster has slots, and the
|
||||||
|
// ledger must converge rather than run forever. This is the free-lunch guard —
|
||||||
|
// reusing the harness's pickBestDamageSpell (which reads the theoretical class
|
||||||
|
// slot table, not the row) would cast an unlimited leveled spell every room.
|
||||||
|
//
|
||||||
|
// It converges BELOW the full pool, and that is correct: a cleric owns no damage
|
||||||
|
// spell at slot 2 or 4 (guiding_bolt and inflict_wounds are L1, spirit_guardians
|
||||||
|
// L3, flame_strike L5), and a trash room never upcasts. Those slots are not
|
||||||
|
// wasted — the turn engine upcasts them at the elite and the boss, which is what
|
||||||
|
// they are for.
|
||||||
|
func TestAutoCast_SlotPoolIsFiniteAndConverges(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid, c := autocastCleric(t, "finite")
|
||||||
|
|
||||||
|
before, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool := 0
|
||||||
|
for _, pair := range before {
|
||||||
|
pool += pair[0]
|
||||||
|
}
|
||||||
|
if pool == 0 {
|
||||||
|
t.Fatal("test cleric has no slots at all")
|
||||||
|
}
|
||||||
|
|
||||||
|
cast := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 1000, AC: 14}
|
||||||
|
p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cast(pool + 10)
|
||||||
|
drained := usedSlots(t, uid)
|
||||||
|
if drained == 0 {
|
||||||
|
t.Fatal("the cleric never spent a slot — it is not really casting")
|
||||||
|
}
|
||||||
|
if drained > pool {
|
||||||
|
t.Fatalf("slots used = %d > pool %d — slots are being conjured", drained, pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dry means dry: another twenty rooms must not find another leveled slot.
|
||||||
|
cast(20)
|
||||||
|
if after := usedSlots(t, uid); after != drained {
|
||||||
|
t.Fatalf("slots used went %d → %d after the pool was dry — a leveled spell is being cast for free",
|
||||||
|
drained, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a dry caster still throws a cantrip rather than reverting to a stick.
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 1000, AC: 14}
|
||||||
|
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
t.Fatal("an out-of-slots caster cast nothing at all; a cantrip is free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hand-queued spell wins. applyPendingCast already owns that cast; auto-casting
|
||||||
|
// on top of it would fire two spells in one action.
|
||||||
|
func TestAutoCast_HandQueuedSpellWins(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid, c := autocastCleric(t, "queued")
|
||||||
|
|
||||||
|
c.PendingCast = encodePendingCast(PendingCast{SpellID: "guiding_bolt", SlotLevel: 1})
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
t.Fatal("auto-cast fired on top of a hand-queued spell — that is two spells in one action")
|
||||||
|
}
|
||||||
|
if used := usedSlots(t, uid); used != 0 {
|
||||||
|
t.Fatalf("slots used = %d; the auto-cast must not spend anything when the player already queued", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A martial has no spellbook and must be left exactly as it was — the balance
|
||||||
|
// corpus rests on the martials not moving.
|
||||||
|
func TestAutoCast_MartialIsUntouched(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
s := &SimRunner{P: p}
|
||||||
|
uid := id.UserID("@autocast-fighter:example.org")
|
||||||
|
c, err := s.BuildCharacter(uid, ClassFighter, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
|
||||||
|
t.Fatal("a fighter cast a spell")
|
||||||
|
}
|
||||||
|
if mods.SpellPreDamage != 0 {
|
||||||
|
t.Fatalf("fighter picked up %d spell pre-damage", mods.SpellPreDamage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never upcast in a trash room: the big slots are what the elite and the boss
|
||||||
|
// are for, and the turn engine spends them there.
|
||||||
|
func TestAutoCast_DoesNotBurnTheBigSlots(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid, c := autocastCleric(t, "native")
|
||||||
|
|
||||||
|
spell, slot, ok := pickAutoResolveSpell(uid, c)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("cleric picked nothing")
|
||||||
|
}
|
||||||
|
if slot != spell.Level {
|
||||||
|
t.Fatalf("%s cast at slot %d, native level %d — an ordinary room must not upcast",
|
||||||
|
spell.ID, slot, spell.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The boss kit survives the walk in. This is the regression the first cut of §6
|
||||||
|
// shipped and the sweep caught: with no cap, a caster spent its whole pool on
|
||||||
|
// goblins and reached the boss empty — room deaths fell but boss deaths tripled
|
||||||
|
// (mage 19 → 61, sorcerer 15 → 65). "Never upcast" is not a reserve, because a
|
||||||
|
// mage's native-level spells ARE its boss kit.
|
||||||
|
//
|
||||||
|
// So: grind a full expedition's worth of rooms, and every slot above the cap
|
||||||
|
// must still be sitting there untouched when the dragon opens its eyes.
|
||||||
|
func TestAutoCast_ReservesTheBossSlots(t *testing.T) {
|
||||||
|
for _, class := range []DnDClass{ClassMage, ClassSorcerer, ClassCleric, ClassWarlock} {
|
||||||
|
t.Run(string(class), func(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
s := &SimRunner{P: p}
|
||||||
|
uid := id.UserID("@autocast-reserve-" + string(class) + ":example.org")
|
||||||
|
c, err := s.BuildCharacter(uid, class, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long expedition: far more rooms than the caster has slots.
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
var mods CombatModifiers
|
||||||
|
stats := CombatStats{MaxHP: 100, AC: 14}
|
||||||
|
enemy := CombatStats{MaxHP: 1000, AC: 14}
|
||||||
|
p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy)
|
||||||
|
}
|
||||||
|
|
||||||
|
slots, err := getSpellSlots(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for lvl, pair := range slots {
|
||||||
|
if lvl <= roomSlotCap {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pair[1] != 0 {
|
||||||
|
t.Errorf("level-%d slots: %d of %d spent in ordinary rooms — "+
|
||||||
|
"that is the boss's kit, and the caster will arrive empty",
|
||||||
|
lvl, pair[1], pair[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
advChar *AdventureCharacter
|
advChar *AdventureCharacter
|
||||||
equip map[EquipmentSlot]*AdvEquipment
|
equip map[EquipmentSlot]*AdvEquipment
|
||||||
mods CombatModifiers
|
mods CombatModifiers
|
||||||
|
// companion marks the hired NPC seat: it fights, but it owns none of the
|
||||||
|
// character-scoped effects the close-out loop applies, and its dndChar /
|
||||||
|
// advChar / equip are nil precisely so a missed guard panics loudly here
|
||||||
|
// rather than silently writing rows for a bot.
|
||||||
|
companion bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -58,6 +63,10 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
builds []seatBuild
|
builds []seatBuild
|
||||||
seated []id.UserID
|
seated []id.UserID
|
||||||
enemy Combatant
|
enemy Combatant
|
||||||
|
// Parallel to players: what each seat is worth, priced once the roster is
|
||||||
|
// final. A member who sat the encounter out is in none of these.
|
||||||
|
levels []int
|
||||||
|
companions []bool
|
||||||
)
|
)
|
||||||
|
|
||||||
for i, uid := range roster {
|
for i, uid := range roster {
|
||||||
@@ -69,6 +78,29 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
return PartyCombatResult{}, nil, err
|
return PartyCombatResult{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The hired companion fights here too — the auto-resolve path is where
|
||||||
|
// most expedition rooms are actually decided. He is synthesized rather
|
||||||
|
// than loaded, and his seatBuild is flagged so the close-out loop below
|
||||||
|
// gives him no XP, no loot, and no post-combat persistence.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
expID := companionExpeditionFor(roster[0])
|
||||||
|
class, level := companionLoadout(expID)
|
||||||
|
player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
||||||
|
// The wounds he carries into this fight. His synthetic sheet is always
|
||||||
|
// written at full HP, so applyDnDHPScaling left StartHP at the
|
||||||
|
// "enter at MaxHP" sentinel and he healed himself for free between every
|
||||||
|
// room of the run. See companionSeatHP.
|
||||||
|
player.Stats.StartHP = companionSeatHP(expID, player.Stats.MaxHP)
|
||||||
|
if leader {
|
||||||
|
enemy = e
|
||||||
|
}
|
||||||
|
players = append(players, player)
|
||||||
|
builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true})
|
||||||
|
seated = append(seated, uid)
|
||||||
|
levels, companions = append(levels, level), append(companions, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
advChar, err := loadAdvCharacter(uid)
|
advChar, err := loadAdvCharacter(uid)
|
||||||
if err != nil || advChar == nil {
|
if err != nil || advChar == nil {
|
||||||
if leader {
|
if leader {
|
||||||
@@ -120,6 +152,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
// Combatant once, before the fight runs. The queued spell can also debuff
|
// Combatant once, before the fight runs. The queued spell can also debuff
|
||||||
// the shared enemy, so every seat's cast lands on the one stat block.
|
// the shared enemy, so every seat's cast lands on the one stat block.
|
||||||
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
||||||
|
// §6 — and if they queued nothing, they still cast. This engine has no
|
||||||
|
// action picker, so before this a caster on autopilot swung a weapon for
|
||||||
|
// the whole fight and their spellbook may as well not have existed. The
|
||||||
|
// slot is really spent; see autoCastForAutoResolve.
|
||||||
|
p.autoCastForAutoResolve(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
||||||
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
|
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
|
||||||
|
|
||||||
players = append(players, player)
|
players = append(players, player)
|
||||||
@@ -127,8 +164,21 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
||||||
})
|
})
|
||||||
seated = append(seated, uid)
|
seated = append(seated, uid)
|
||||||
|
lvl := 0
|
||||||
|
if dndChar != nil {
|
||||||
|
lvl = dndChar.Level
|
||||||
|
}
|
||||||
|
levels, companions = append(levels, lvl), append(companions, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What each seat costs the enemy, priced against the leader — over the seats
|
||||||
|
// that were actually seated, so a member left behind is not charged.
|
||||||
|
ptrs := make([]*Combatant, len(players))
|
||||||
|
for i := range players {
|
||||||
|
ptrs[i] = &players[i]
|
||||||
|
}
|
||||||
|
applySeatWeights(ptrs, levels, companions)
|
||||||
|
|
||||||
res := simulateParty(players, enemy, phases)
|
res := simulateParty(players, enemy, phases)
|
||||||
dumpCombatEventsIfDebug(
|
dumpCombatEventsIfDebug(
|
||||||
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
|
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
|
||||||
@@ -137,6 +187,19 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
for i, b := range builds {
|
for i, b := range builds {
|
||||||
seatRes := res.Seats[i]
|
seatRes := res.Seats[i]
|
||||||
|
|
||||||
|
// The companion swings and then goes back to filing copy: no inventory to
|
||||||
|
// deduct from, no sheet to persist to, no XP to earn. Every call below
|
||||||
|
// would either write rows for a bot or log an error about the rows it
|
||||||
|
// hasn't got.
|
||||||
|
//
|
||||||
|
// His HP is the exception, and it is not bookkeeping — it is the fight's
|
||||||
|
// result. Without it he re-enters the next room at full while the humans
|
||||||
|
// beside him carry every wound to camp.
|
||||||
|
if b.companion {
|
||||||
|
_ = setCompanionHP(companionExpeditionFor(roster[0]), seatRes.PlayerEndHP)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the actual heal items consumed during combat (one inventory item
|
// Remove the actual heal items consumed during combat (one inventory item
|
||||||
// per heal_item event this seat fired). Cheapest-tier first.
|
// per heal_item event this seat fired). Cheapest-tier first.
|
||||||
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
|
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
|
||||||
@@ -193,6 +256,12 @@ func (p *AdventurePlugin) closeOutZoneWin(
|
|||||||
) (leaderDrop string, downed []id.UserID) {
|
) (leaderDrop string, downed []id.UserID) {
|
||||||
party := len(seated) > 1
|
party := len(seated) > 1
|
||||||
for i, uid := range seated {
|
for i, uid := range seated {
|
||||||
|
// The companion takes no cut and cannot die. He is not in the downed list
|
||||||
|
// either: that list is what the room narration mourns, and the party did
|
||||||
|
// not lose a friend when the hireling took a nap.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if res.Seats[i].PlayerEndHP > 0 {
|
if res.Seats[i].PlayerEndHP > 0 {
|
||||||
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
|
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
@@ -217,6 +286,12 @@ func (p *AdventurePlugin) closeOutZoneWin(
|
|||||||
// fight.
|
// fight.
|
||||||
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
|
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
|
||||||
for i, uid := range seated {
|
for i, uid := range seated {
|
||||||
|
// The companion is not killed and is not counted among the dead — he is
|
||||||
|
// not in the graveyard, and a wipe that lists him would have the news bot
|
||||||
|
// reporting its own funeral.
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if res.Seats[i].PlayerEndHP <= 0 {
|
if res.Seats[i].PlayerEndHP <= 0 {
|
||||||
markAdventureDead(uid, deathSource, zone.Display)
|
markAdventureDead(uid, deathSource, zone.Display)
|
||||||
killed = append(killed, uid)
|
killed = append(killed, uid)
|
||||||
|
|||||||
12
main.go
12
main.go
@@ -13,6 +13,7 @@ import (
|
|||||||
"gogobee/internal/bot"
|
"gogobee/internal/bot"
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
"gogobee/internal/dreamclient"
|
"gogobee/internal/dreamclient"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
"gogobee/internal/plugin"
|
"gogobee/internal/plugin"
|
||||||
"gogobee/internal/util"
|
"gogobee/internal/util"
|
||||||
"gogobee/internal/version"
|
"gogobee/internal/version"
|
||||||
@@ -55,6 +56,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
db.RecordStartup(version.Version, version.Commit)
|
db.RecordStartup(version.Version, version.Commit)
|
||||||
|
|
||||||
|
// Pete adventure-news seam: wire the outbound fact client from the
|
||||||
|
// environment (no-op unless FEATURE_PETE_NEWS=true). The background sender
|
||||||
|
// is started once the run context exists (below).
|
||||||
|
peteclient.Init()
|
||||||
|
|
||||||
// Create Matrix session. AUTH_MODE selects the transport:
|
// Create Matrix session. AUTH_MODE selects the transport:
|
||||||
// masdevice (default) — MAS OAuth device grant over /sync.
|
// masdevice (default) — MAS OAuth device grant over /sync.
|
||||||
// appservice — as_token auth over Synapse transaction push.
|
// appservice — as_token auth over Synapse transaction push.
|
||||||
@@ -97,6 +103,9 @@ func main() {
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Drain the Pete adventure-news emit queue in the background until shutdown.
|
||||||
|
peteclient.StartSender(ctx)
|
||||||
|
|
||||||
// Show the bot online from boot (appservice mode; no-op under masdevice, where
|
// Show the bot online from boot (appservice mode; no-op under masdevice, where
|
||||||
// /sync refreshes presence implicitly). Safe before the listener — outbound only.
|
// /sync refreshes presence implicitly). Safe before the listener — outbound only.
|
||||||
sess.StartPresence(ctx)
|
sess.StartPresence(ctx)
|
||||||
@@ -160,6 +169,9 @@ func main() {
|
|||||||
// Games & Economy
|
// Games & Economy
|
||||||
euroPlugin := plugin.NewEuroPlugin(client)
|
euroPlugin := plugin.NewEuroPlugin(client)
|
||||||
registry.Register(euroPlugin)
|
registry.Register(euroPlugin)
|
||||||
|
// The web casino's money loop. Not a plugin: it answers to Pete's poll, not
|
||||||
|
// to a Matrix message, so it has no commands and nothing to register.
|
||||||
|
plugin.StartPeteEscrowLoop(ctx, euroPlugin)
|
||||||
registry.Register(plugin.NewFlipPlugin(client))
|
registry.Register(plugin.NewFlipPlugin(client))
|
||||||
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin, dictClient))
|
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin, dictClient))
|
||||||
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
|
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
|
||||||
|
|||||||
225
pete_adventure_news_plan.md
Normal file
225
pete_adventure_news_plan.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Pete / Gogobee Adventure News Integration — quick plan
|
||||||
|
|
||||||
|
> High-level only. No assumptions about current code shape; codebase has moved
|
||||||
|
> since Pete was last touched. This is a scoping doc, not an implementation plan.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A news.parodia.dev section where Pete reports on Adventure module activity:
|
||||||
|
deaths, boss defeats, new players, notable events, and periodic generated
|
||||||
|
recaps. Distinct from the in-bot commands (`!town`, `!graveyard`, `!rivals
|
||||||
|
board`) that already surface this data on request — this makes the same kind
|
||||||
|
of information ambient and discoverable instead of command-gated.
|
||||||
|
|
||||||
|
## Relationship to existing plans
|
||||||
|
|
||||||
|
- **Converges with "Phase 16 Pete" (the deferred Pete bot) — doesn't collide.**
|
||||||
|
The news feed is the near-term, separable slice and ships first. But the
|
||||||
|
Phase 16 bot turns out to be the thing that makes the feed *first-person*: an
|
||||||
|
adventuring Pete is an embedded correspondent who files dispatches from
|
||||||
|
dungeons he's actually in. Feed first, bot later, same character. See "Pete as
|
||||||
|
a character" below.
|
||||||
|
- **Overlaps with Track E (E3 — surfacing the buried social data).** E3 builds
|
||||||
|
`!town`, `!graveyard`, `!rivals board` as pull-based bot commands. Pete's
|
||||||
|
news feed is the push-based counterpart to the same underlying data. Ideally
|
||||||
|
they share one source of truth rather than each growing its own read path.
|
||||||
|
- **Depends on C3 (World boss / "the Siege") shipping.** That's sequenced in
|
||||||
|
N6, well after N4's town work. Pete's biggest story beat — the town under
|
||||||
|
attack — doesn't exist as an event to report on until C3 lands. Fine to plan
|
||||||
|
for now, but the event-feed and stats sections can go live well before this
|
||||||
|
piece is reachable.
|
||||||
|
|
||||||
|
## Content types
|
||||||
|
|
||||||
|
1. **Event feed (low effort, high value)**
|
||||||
|
- Deaths (source, location) — same data `!graveyard` already surfaces.
|
||||||
|
- Boss defeats — first-clears, notable kills.
|
||||||
|
- New player arrivals.
|
||||||
|
- Zone first-clears / notable milestones.
|
||||||
|
- **World boss / "the Siege" (C3).** This is the marquee story beat: a
|
||||||
|
named boss camps outside town for 72h with a shared community HP pool.
|
||||||
|
Pete should report both ends of it — the announcement/start of a Siege,
|
||||||
|
and the resolution (boss falls and payout goes out, or it survives and
|
||||||
|
"loots the town," a visible pot tribute). The town-attack framing already
|
||||||
|
baked into C3's design (it's explicitly a communal threat, not just a
|
||||||
|
tougher solo fight) makes it the best fit for the generated-article
|
||||||
|
treatment, not just a log line.
|
||||||
|
- This is closest to a straightforward log-to-article pass. Good starting
|
||||||
|
point since it needs the least judgment about what's "interesting."
|
||||||
|
|
||||||
|
2. **Generated articles (higher effort, more judgment required)**
|
||||||
|
- Narrative recaps of notable happenings — a boss kill or first-clear
|
||||||
|
written up as a short story beat rather than a log line, in the same
|
||||||
|
spirit as the flavor-voice work already done elsewhere in the bot
|
||||||
|
ecosystem.
|
||||||
|
- LLM-authored via Gogobee's existing inference access, following the same
|
||||||
|
pattern Petal already established: model as a config value behind an
|
||||||
|
abstraction, not hardcoded. Pete's news writing shouldn't need its own
|
||||||
|
bespoke integration if that seam already exists.
|
||||||
|
- Event data (who, what, where, stakes) stays structured and feeds the
|
||||||
|
model as context; the model's job is prose, not fact invention. Worth
|
||||||
|
deciding early whether there's a review/approval step before publish, or
|
||||||
|
whether it goes straight to the site — the risk profile is different
|
||||||
|
between "reads a little dry" and "confidently wrong about who died."
|
||||||
|
- Open question: live per-event, or batched into a daily/weekly digest.
|
||||||
|
Batching gives editorial control over what's worth writing up instead of
|
||||||
|
reporting every routine kill, and is also cheaper on inference calls than
|
||||||
|
firing one generation per event — probably the safer default to start.
|
||||||
|
|
||||||
|
3. **Player stats**
|
||||||
|
- Needs the same leak-check the engagement plan already flags for E3:
|
||||||
|
nothing that ranks or badges donation counts (Misty/Arina), since that
|
||||||
|
could let players reverse-engineer secret NPC buffs. Tax-ledger totals
|
||||||
|
were called out as safe; donation counts were not. Any stat Pete
|
||||||
|
considers for public display should be run through that same filter
|
||||||
|
before it's greenlit, not decided ad hoc per-stat.
|
||||||
|
- Safer starting stats: playtime/level distributions, zone clear counts,
|
||||||
|
arena standings, rival board — anything already deemed room-safe under
|
||||||
|
E3's design.
|
||||||
|
|
||||||
|
## Sequencing suggestion
|
||||||
|
|
||||||
|
1. Event feed first (deaths, boss defeats, new players) — mechanical, low
|
||||||
|
judgment, reuses E3's data model.
|
||||||
|
2. Stats section next, gated through the leak-check before anything ships.
|
||||||
|
3. Generated articles last — highest creative/quality bar, and benefits from
|
||||||
|
having the event feed already running as its raw material.
|
||||||
|
|
||||||
|
## Resolved decisions
|
||||||
|
|
||||||
|
- **Public-facing — yes.** The wall isn't what protects players; the leak-check
|
||||||
|
and name-scoping are, and those apply either way. So publish publicly, but
|
||||||
|
only the public-safe subset, decided on purpose:
|
||||||
|
- **Safe to publish:** boss first-clears, Siege announcements/resolutions,
|
||||||
|
zone clear counts, arena standings, aggregate level/playtime distributions,
|
||||||
|
the rival board.
|
||||||
|
- **Community-only or omit:** anything donation-derived (Misty/Arina secret
|
||||||
|
buffs), anything that names a Matrix account, and raw per-player time series
|
||||||
|
fine-grained enough to diff and reverse-engineer a hidden buff. Public
|
||||||
|
raises the adversary from "a curious member" to "anyone with a scraper," so
|
||||||
|
the leak-check is load-bearing, not advisory.
|
||||||
|
- **Name-scoping:** report on **character names only**, never the Matrix
|
||||||
|
handle behind them. Consider a per-player opt-out of being named in
|
||||||
|
articles. Decide before launch — retroactively scrubbing an indexed/cached
|
||||||
|
public page is the URL-preview problem again.
|
||||||
|
|
||||||
|
## Pete as a character (forward tier — after the feed + section land)
|
||||||
|
|
||||||
|
The most ambitious slice, and the payoff for the whole arc: Pete stops being a
|
||||||
|
wire service that reports on others and becomes a **playable character in the
|
||||||
|
world** — the realm's embedded correspondent. Converges with the deferred
|
||||||
|
Phase 16 Pete bot. Two surfaces, both feeding the news:
|
||||||
|
|
||||||
|
1. **Solo runs.** Pete runs his own expeditions on autopilot (the existing
|
||||||
|
`runAutopilotWalk` path the sim already drives) and files first-person
|
||||||
|
dispatches: *"Your correspondent nearly didn't make it out of the
|
||||||
|
Underforge."* His deaths/level-ups/kills emit the same fact payload every
|
||||||
|
player does, so the news writes itself — and the fact-guard is trivially safe
|
||||||
|
when the subject is Pete himself.
|
||||||
|
|
||||||
|
2. **Hireable companion (class-fluid).** Players short on help can hire Pete
|
||||||
|
into their party. Unlike a player character he is **role-fluid** — he fills
|
||||||
|
the gap: no healer → Cleric, no damage → Mage, need a body up front →
|
||||||
|
Fighter. Default behavior is auto-fill the missing role, with a manual
|
||||||
|
override. When hired, that expedition becomes a dispatch too: *"Filled in as
|
||||||
|
cleric for [party] today — nobody died on my watch."*
|
||||||
|
|
||||||
|
3. **Standing rival (the house challenger).** Players can challenge Pete to a
|
||||||
|
duel any time via the existing C2 staked/no-death system — so there's always
|
||||||
|
an opponent even when the game is quiet (same trailing-case lift as hiring,
|
||||||
|
for the PvP side). He **responds to win and loss himself**, first person and
|
||||||
|
gracious either way — his losses are the better content. First time anyone
|
||||||
|
beats him is an alert. Rival results feed the news via `adventure_rival_records`.
|
||||||
|
|
||||||
|
### Identity — Pete owns Pete (corrected after reading the code)
|
||||||
|
|
||||||
|
`@pete:parodia.dev` is already an **independent bot** (pete repo) with its own
|
||||||
|
account; gogobee ignores it via `IGNORED_BOTS`. So Pete is NOT a gogobee
|
||||||
|
appservice ghost — his Matrix presence is his own bot, and **Pete owns his own
|
||||||
|
voice** (persona, templates, editorial). See `project_pete_bot_architecture`.
|
||||||
|
|
||||||
|
- **Pete's surfaces (all his bot):** the news website, his Matrix posts, and —
|
||||||
|
for the character tier — duel reactions / hire banter / replies. He's already
|
||||||
|
an in-room command bot (`!post`, `!petestats`), so he has the interactive
|
||||||
|
skeleton; the character behaviors extend it.
|
||||||
|
- **gogobee's job:** emit game-event *facts* to Pete, and provide LLM *compute*
|
||||||
|
via a generic endpoint. It does NOT voice Pete. The `📣 Pete:` broadcast lines
|
||||||
|
currently hardcoded in gogobee's flavor file are the old two-Petes situation
|
||||||
|
and should migrate to facts Pete voices.
|
||||||
|
- **LLM:** Pete stays LLM-free in his own codebase (deliberate — it was
|
||||||
|
removed). When he wants prose he calls gogobee's generic tailnet inference
|
||||||
|
endpoint. Default is template-only; LLM is opt-in per marquee beat.
|
||||||
|
|
||||||
|
Why this fits the existing guardrails:
|
||||||
|
|
||||||
|
- **It's the sanctioned difficulty lever.** A short-handed / missing-a-role
|
||||||
|
party is the trailing case; a hireable Pete lifts exactly that party without
|
||||||
|
nerfing leaders or touching monster scaling. Tune him as a competent
|
||||||
|
*below-median* member of whatever class he plays — help, never a carry.
|
||||||
|
- **Balance corpus stays clean.** Golden pins run solo, so baseline sims never
|
||||||
|
hire Pete. Hiring is a live player-help feature entirely outside the baseline;
|
||||||
|
the two paths never cross.
|
||||||
|
- **Gold sink.** Hiring costs gold per expedition — economy pressure of the same
|
||||||
|
kind the tax-ledger / lottery tuning already wants. Scarcity is a knob:
|
||||||
|
Pete "on assignment" (off covering a Siege) is both a reason he's sometimes
|
||||||
|
unavailable and a story in itself.
|
||||||
|
- **Duel economy caution.** As a standing rival he's *beatable* by design — so
|
||||||
|
his duel stakes must be bragging-rights / bounded, NOT real gold, or players
|
||||||
|
farm the house challenger for income. Tuning-pass number; flagged so it isn't
|
||||||
|
discovered as an exploit.
|
||||||
|
- **Guardrails.** Pete keeps clear of the Misty/Arina secret-buff mechanics (a
|
||||||
|
public reporter benefiting from hidden buffs and reporting outcomes is a leak
|
||||||
|
vector). His voice is his own — NOT TwinBee's.
|
||||||
|
|
||||||
|
Open tuning-pass questions (not blockers): hire cost (flat vs level-scaled);
|
||||||
|
his power scaling (fixed level vs scales-to-party); availability (always vs
|
||||||
|
cooldown / on-assignment scarcity).
|
||||||
|
|
||||||
|
## Gaps & risks register
|
||||||
|
|
||||||
|
The design's blind spot was treating the gogobee→Pete channel as internal and
|
||||||
|
trusted — it's neither once player-named characters and LLM prose flow onto a
|
||||||
|
public front page. Seven gaps, three of them must-solve-before-launch.
|
||||||
|
|
||||||
|
1. **[MUST — security] Untrusted input into LLM prompt + public HTML.** A
|
||||||
|
character name is player-controlled. It reaches (a) the LLM prompt →
|
||||||
|
prompt-injection, and (b) rendered `content` → XSS. Fix: names get escaped
|
||||||
|
before the prompt; Adventure `content` goes through Pete's **existing** RSS
|
||||||
|
sanitizer (it already strips inline scripts), never around it.
|
||||||
|
2. **[MUST] `article_url` has nowhere to point.** Schema is `NOT NULL`; a
|
||||||
|
self-hosted event has no external article. Point it at a Pete permalink
|
||||||
|
(`/adventure/<guid>` / the existing per-story reader page).
|
||||||
|
3. **[MUST] Visual identity.** No OG images → the section looks broken next to
|
||||||
|
RSS cards. Ship per-event-type art (Siege banner, graveyard mark, Pete's
|
||||||
|
avatar for his own dispatches).
|
||||||
|
4. **[RESOLVED] Selection/aggregation thresholds.** Data-derived from the prod
|
||||||
|
DB — see the thresholds section in the voice spec. Dormant community → the
|
||||||
|
risk is emptiness, not spam; near-inclusive, with grind telemetry filtered
|
||||||
|
out and achievements rarity-gated.
|
||||||
|
5. **[RESOLVED — security] Opt-out.** Default-featured (informed, announced at
|
||||||
|
launch), one gogobee command backed by a `news_optout` table (mirrors the
|
||||||
|
existing `shade_optout`). Enforced at emit time. Opt-out **anonymizes, not
|
||||||
|
deletes** ("an adventurer…"). Retroactive: `POST /ingest/adventure/scrub`
|
||||||
|
anonymizes existing rows; whole section is `noindex` to bound the
|
||||||
|
cached-forever problem.
|
||||||
|
6. **[RESOLVED — security] Kill-switch.** Global `adventure_news_enabled` flag
|
||||||
|
in gogobee kills emission at the source (generation is gogobee-side, so this
|
||||||
|
is the real switch). Per-story **admin delete on Pete**, reusing the OIDC
|
||||||
|
`adminSubs` gate that already guards `/status`.
|
||||||
|
7. **[RESOLVED] Cold-start backfill.** Guarded one-shot in gogobee replays the
|
||||||
|
back-catalog (realm-firsts, deaths, notable clears, single-holder
|
||||||
|
achievements) through the emit path. Backdated `published_at`, dispatch tone,
|
||||||
|
**no web-push**. Idempotent on `guid`; kept as a marked one-shot (not deleted
|
||||||
|
at close-out) so fresh deploys don't re-ghost-town. At this volume, backfill
|
||||||
|
is most of the launch content, not a nice-to-have.
|
||||||
|
|
||||||
|
## Open questions to settle before scoping further
|
||||||
|
|
||||||
|
- Does Pete write live off events, or on a batch cadence?
|
||||||
|
- Single source of truth for this data shared with E3's bot commands, or a
|
||||||
|
separate read path for the news site?
|
||||||
|
- Voice: does Pete have an established voice yet, or does this news section
|
||||||
|
define it?
|
||||||
|
- Which model tier for article generation — same one Petal's conversational
|
||||||
|
side uses, or a separate config entry, given the different latency/quality
|
||||||
|
tradeoffs of a batch job versus interactive chat?
|
||||||
224
pete_adventure_news_voice.md
Normal file
224
pete_adventure_news_voice.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Pete × Parodia — Adventure section voice + architecture spec
|
||||||
|
|
||||||
|
> Companion to `pete_adventure_news_plan.md`. Pete is an independent LLM-free bot
|
||||||
|
> that OWNS his voice; gogobee is the game-event source + generic LLM compute.
|
||||||
|
> Voice: a warm, friendly reporter (owner's call). See
|
||||||
|
> `project_pete_bot_architecture` memory.
|
||||||
|
|
||||||
|
## Architecture (the load-bearing part)
|
||||||
|
|
||||||
|
- **Pete owns voice, authoring, identity, publishing.** Persona, templates,
|
||||||
|
editorial, the `@pete:parodia.dev` account. Pete decides what is said and says
|
||||||
|
it. Pete's codebase has **zero** Ollama/vLLM connectivity, by design.
|
||||||
|
- **gogobee owns game events + raw LLM compute.** Source of truth for what
|
||||||
|
happened; the only codebase that touches the models.
|
||||||
|
- **gogobee → Pete: structured event *facts*** (the payload below), NOT finished
|
||||||
|
prose. gogobee is compute, not ghostwriter.
|
||||||
|
- **Pete → gogobee: a generic, voice-agnostic inference endpoint.** Prompt in,
|
||||||
|
completion out — gogobee never sees "Pete." Pete builds the voiced prompt in
|
||||||
|
his own codebase and calls it. Reusable for Pete's other channels later.
|
||||||
|
- **Network:** both directions ride the Headscale tailnet (Parodia ↔ LAN box).
|
||||||
|
gogobee's inference endpoint is tailnet-bound + bearer-authed; Ollama/vLLM
|
||||||
|
stays `localhost` on the LAN box, never on the tailnet.
|
||||||
|
|
||||||
|
## Voice — Pete the friendly reporter
|
||||||
|
|
||||||
|
Pete is a **reporter, and a warm one** — think a beloved local newscaster who
|
||||||
|
genuinely knows everyone in town and is glad to see you. Journalistic bones
|
||||||
|
(clear headline, a who/what/where/when lede, gets the facts right) delivered
|
||||||
|
with a personable, first-person warmth. He roots for the community, celebrates
|
||||||
|
wins, mourns losses gently, and welcomes newcomers by name. Conversational,
|
||||||
|
never snarky. Friendly, not a caps-lock hype-man.
|
||||||
|
|
||||||
|
- Celebratory win: *"What a turnout — the town held the line tonight."*
|
||||||
|
- Gentle loss: *"Sad news to pass along today."*
|
||||||
|
- Warm welcome: *"Say hello if you see them out there."*
|
||||||
|
- His own sign-off: a short reporter's tag closes bigger pieces — *"Reporting
|
||||||
|
from {region}, this is Pete."*
|
||||||
|
|
||||||
|
Tiering is by **prefix + placement**, not by shouting:
|
||||||
|
- **PRIORITY** (rare, live): Sieges, world-firsts, deaths, a new arrival, the
|
||||||
|
first time Pete loses a duel. Posted immediately.
|
||||||
|
- **BULLETIN** (batched daily digest): everything else worth noting.
|
||||||
|
|
||||||
|
Warmth carries the register, not exclamation marks. A Siege is urgent but Pete
|
||||||
|
is steady and rallying, not panicked: *"Folks, this is the big one — and the
|
||||||
|
whole community's needed. Let's rally."*
|
||||||
|
|
||||||
|
## Hard rules (govern templates AND any LLM prose)
|
||||||
|
|
||||||
|
1. **Character names only.** Never the Matrix handle behind a character.
|
||||||
|
2. **No donation-derived anything** (Misty/Arina secret buffs).
|
||||||
|
3. **Facts are supplied, never invented.** Names/numbers/outcomes come only from
|
||||||
|
the payload `actors[]` allow-list; the fact-guard rejects anything else.
|
||||||
|
4. **Public front-page surface.** Safe-subset rule from the plan applies.
|
||||||
|
|
||||||
|
## Fact payload (gogobee → Pete)
|
||||||
|
|
||||||
|
Flat, pre-sanitized. `actors[]` is the ONLY set of names allowed in output.
|
||||||
|
Character names are player-controlled → sanitize before they enter a prompt or
|
||||||
|
rendered HTML (see plan gap #1).
|
||||||
|
|
||||||
|
```
|
||||||
|
event_type siege_start | siege_win | siege_loss | boss_first | boss_kill |
|
||||||
|
zone_first | zone_clear | death | retreat | arrival | standings |
|
||||||
|
rival_result | pete_duel_loss | pete_duel_win | milestone
|
||||||
|
// GUID prefix == event_type (permalink derives its label from it)
|
||||||
|
tier priority | bulletin
|
||||||
|
actors[] ["Brannigan", ...] // allow-list for output
|
||||||
|
subject "Brannigan"
|
||||||
|
opponent "Kif" // duels / rivalries
|
||||||
|
boss "The Hollow King"
|
||||||
|
zone "Dragon's Lair"
|
||||||
|
region "the Underforge"
|
||||||
|
level 14
|
||||||
|
count 37 // defenders, clears, etc.
|
||||||
|
outcome won | lost | ...
|
||||||
|
stakes "72h" / figure
|
||||||
|
occurred_at unix
|
||||||
|
```
|
||||||
|
|
||||||
|
## Templates (Pete the friendly reporter)
|
||||||
|
|
||||||
|
Format: `TIER · cadence`. `content` (article body) is optional LLM prose via the
|
||||||
|
gogobee endpoint; headline/lede are always template — deterministic and safe.
|
||||||
|
|
||||||
|
### siege_start — PRIORITY · live
|
||||||
|
- **headline:** `Breaking: {boss} is marching on the town.`
|
||||||
|
- **lede:** `Folks, this is the big one — {boss} has camped outside the gates and the whole community's needed to turn it back. You've got {stakes}. Let's rally.`
|
||||||
|
|
||||||
|
### siege_win — PRIORITY · live
|
||||||
|
- **headline:** `The town holds! {boss} turned back.`
|
||||||
|
- **lede:** `What a turnout — {count} defenders stood shoulder to shoulder and sent {boss} packing. Spoils are going out now. Proud of you all.`
|
||||||
|
|
||||||
|
### siege_loss — PRIORITY · live
|
||||||
|
- **headline:** `Heavy news: {boss} broke through.`
|
||||||
|
- **lede:** `We gave it everything, but {boss} got past the gates and took its tribute. We'll be ready next time — heads up, everyone.`
|
||||||
|
|
||||||
|
### boss_first — PRIORITY · live
|
||||||
|
- **headline:** `First ever: {subject} brings down {boss}.`
|
||||||
|
- **lede:** `History in {region} today — {subject} is the first anyone's seen clear {boss}. Nobody had done it before. Hats off.`
|
||||||
|
|
||||||
|
### zone_first — PRIORITY (realm-first) · live
|
||||||
|
- **headline:** `{zone} cleared for the very first time.`
|
||||||
|
- **lede:** `{subject} made it through {zone} in {region}{, at level {level}}. Nicely done.`
|
||||||
|
|
||||||
|
### zone_clear — BULLETIN (repeat) · batch
|
||||||
|
- **headline:** `{subject} clears {zone}.`
|
||||||
|
- **lede:** same as zone_first (shared).
|
||||||
|
- gogobee emits `zone_first` (priority) only for the realm's first clear of a
|
||||||
|
zone and `zone_clear` (bulletin) for every later clear, so a repeat is never
|
||||||
|
filed under "first ever" — headline, permalink label, and emblem all match.
|
||||||
|
|
||||||
|
### boss_kill — BULLETIN · batch
|
||||||
|
- **headline:** `{subject} takes down {boss} again.`
|
||||||
|
- **lede:** `Another clean run in {zone} today. Routine for {subject} by now — but still worth a nod.`
|
||||||
|
|
||||||
|
### death — PRIORITY · live
|
||||||
|
- **headline:** `We lost {subject} in {zone}.`
|
||||||
|
- **lede:** `Sad news to pass along: {subject} fell at level {level} in {zone}. The graveyard's a little fuller tonight. Rest easy.`
|
||||||
|
|
||||||
|
### arrival — BULLETIN · batch
|
||||||
|
- **headline:** `Welcome to the realm, {subject}!`
|
||||||
|
- **lede:** `A new {class_race} just walked through the gates. Say hello if you see them out there.`
|
||||||
|
|
||||||
|
### standings — BULLETIN · batch
|
||||||
|
- **headline:** `The rival board's been shaken up.`
|
||||||
|
- **lede:** `{subject} is on the move — here's where the standings sit today.`
|
||||||
|
|
||||||
|
### rival_result — BULLETIN · batch (Pete uninvolved)
|
||||||
|
- **headline:** `{subject} settles the score with {opponent}.`
|
||||||
|
- **lede:** `Their duel went {subject}'s way today, and the board reflects it. Good match, you two.`
|
||||||
|
|
||||||
|
### pete_duel_loss — PRIORITY (first-ever) / BULLETIN · live · FIRST PERSON
|
||||||
|
- **headline (first-ever):** `You got me, {subject}.`
|
||||||
|
- **headline (repeat):** `{subject} got the better of me again.`
|
||||||
|
- **lede:** `Credit where it's due — {subject} beat me fair and square. Good duel. I'll want a rematch when you're ready.`
|
||||||
|
|
||||||
|
### pete_duel_win — BULLETIN · batch · FIRST PERSON
|
||||||
|
- **headline:** `Held my ground against {subject} today.`
|
||||||
|
- **lede:** `Closer than the record will show, honestly — {subject} pushed me. Rematch whenever you like.`
|
||||||
|
|
||||||
|
### retreat — BULLETIN · batch
|
||||||
|
- **headline:** `{subject} backed out of {zone}.`
|
||||||
|
- **lede:** `{subject} turned around {N days in} — {zone} got the better of them
|
||||||
|
this time, at level {level}, and they made the call to walk out rather than
|
||||||
|
push it. Everybody came home breathing, which is the bit that counts. That
|
||||||
|
dungeon'll still be there next week.`
|
||||||
|
- An expedition that ended with the player ALIVE (a solo clock-out retreat, or a
|
||||||
|
party the turn engine broke off). `count` = the day they reached.
|
||||||
|
- Emitted from gogobee's one run-loss chokepoint, gated on the reason: a **death**
|
||||||
|
files its own priority dispatch and must never ALSO appear as a retreat, and an
|
||||||
|
**idle reap** is never filed at all — a player who closed their laptop did not
|
||||||
|
flee anything, and saying so by name would be a lie about a person.
|
||||||
|
- Warm, not a failure notice. Lead with everyone coming home.
|
||||||
|
|
||||||
|
### milestone — BULLETIN · batch
|
||||||
|
- **headline:** `{subject} hits {milestone}.`
|
||||||
|
- **lede:** `One for the books — {subject} just reached {milestone}. The long road continues.`
|
||||||
|
|
||||||
|
## Optional LLM prose (Pete authors, gogobee computes)
|
||||||
|
|
||||||
|
For the PRIORITY beats only, Pete MAY expand `content` into a short friendly
|
||||||
|
write-up in his reporter voice. Pete builds the prompt (his persona + the fact
|
||||||
|
payload) in Pete's
|
||||||
|
codebase and calls gogobee's generic inference endpoint; gogobee returns raw
|
||||||
|
text; Pete posts it. gogobee never authors — it computes.
|
||||||
|
|
||||||
|
Given you removed LLM from Pete deliberately, template-only is the safe default
|
||||||
|
and the data supports it (see thresholds — tiny volume). LLM prose is opt-in per
|
||||||
|
beat, not the baseline.
|
||||||
|
|
||||||
|
### Fallback ladder (never blocks publish)
|
||||||
|
Headline + lede are always template-derived, so Pete publishes regardless.
|
||||||
|
`content` LLM expansion is best-effort:
|
||||||
|
1. Endpoint disabled in Pete config → template body.
|
||||||
|
2. Endpoint unreachable / timeout → template body.
|
||||||
|
3. Empty or malformed completion → template body.
|
||||||
|
4. Fact-guard fails (a name not in `actors[]`, or a number that contradicts the
|
||||||
|
payload) → template body. Guard runs in Pete, on the completion.
|
||||||
|
|
||||||
|
## Selection thresholds (data-derived, prod DB July 2026)
|
||||||
|
|
||||||
|
Measured against live prod (`millenia`, `data/gogobee.db`). Community is
|
||||||
|
**dormant** — 4 players ever (L20–49). Risk is *emptiness*, not spam;
|
||||||
|
near-inclusive.
|
||||||
|
|
||||||
|
Observed (Apr–Jul 2026): deaths ~3 total; boss kills 23 / 62 days; milestones
|
||||||
|
43 / 60 days; achievements 181 (only flood-prone type); arena runs 82 / rivals
|
||||||
|
6; Sieges 0 ever.
|
||||||
|
|
||||||
|
**Negative filter (matters most):** high-volume tables are grind telemetry, NOT
|
||||||
|
news — `adventure_activity_log` successes (mining/fishing/foraging) and
|
||||||
|
expedition-log `interrupt|walk|harvest|ambient|rest|night`. Never publish. Only
|
||||||
|
`milestone`/`recap` entry types, deaths, and boss/zone firsts surface.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- **PRIORITY (live, individual):** realm-first zone clear, realm-first boss kill,
|
||||||
|
any Siege start/resolve, new-player arrival, any death, first-ever Pete duel loss.
|
||||||
|
- **BULLETIN (daily digest):** repeat boss kills, repeat zone clears,
|
||||||
|
standings/rival shifts, Pete duel wins.
|
||||||
|
- **Rarity-gated:** an achievement publishes only if held by a *single* player.
|
||||||
|
- **Never published:** harvest/mining/fishing outcomes, expedition
|
||||||
|
interrupt/walk/ambient/rest noise.
|
||||||
|
- **Aggregation:** collapse to a count line only if the same `(event_type,
|
||||||
|
target)` recurs **≥3×** in a digest window (historically never triggers).
|
||||||
|
- **Cadence:** bulletin = daily digest; priority fires live.
|
||||||
|
|
||||||
|
## Delivery seam
|
||||||
|
|
||||||
|
### gogobee → Pete: event facts
|
||||||
|
- Transport: gogobee POSTs the fact payload to Pete over the tailnet
|
||||||
|
(or Pete's HTTPS ingest), bearer-authed.
|
||||||
|
- Idempotent on a stable event key (`siege_win:<ts>` etc.) so retries are no-ops.
|
||||||
|
- gogobee owns delivery reliability: queue + retry so a Pete restart loses
|
||||||
|
nothing.
|
||||||
|
- Adventure output is Pete-on-Matrix + the website. gogobee must NOT also
|
||||||
|
announce these itself — the `📣 Pete:` broadcast lines currently hardcoded in
|
||||||
|
gogobee's flavor file should migrate to facts Pete voices (see plan).
|
||||||
|
|
||||||
|
### Pete → gogobee: generic inference
|
||||||
|
- Pete POSTs `{prompt, params}` to gogobee's tailnet inference endpoint,
|
||||||
|
bearer-authed. Completion returned as raw text. gogobee proxies to
|
||||||
|
`localhost` Ollama/vLLM.
|
||||||
|
- Voice-agnostic: gogobee has no Adventure/Pete-specific logic on this path.
|
||||||
Reference in New Issue
Block a user