mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-19 10:22:41 +00:00
Compare commits
32 Commits
mischief-m
...
equip-revi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8d21f780 | ||
|
|
68c8cdff2d | ||
|
|
b29dcf4360 | ||
|
|
1f62a8e842 | ||
|
|
6e2782ac48 | ||
|
|
7e59697754 | ||
|
|
22b7949791 | ||
|
|
fbed45fc96 | ||
|
|
7960838b3f | ||
|
|
32520eb7ec | ||
|
|
b6d4e4ccec | ||
|
|
479f77b9c5 | ||
|
|
189a44e1eb | ||
|
|
db13ed75b9 | ||
|
|
686434f8e3 | ||
|
|
fc9e055083 | ||
|
|
85e5ba5fce | ||
|
|
d08a20a114 | ||
|
|
2e73cae418 | ||
|
|
d9541f07f1 | ||
|
|
27c2b48007 | ||
|
|
c9282cb18a | ||
|
|
11bfce780c | ||
|
|
ba306f0b59 | ||
|
|
6d402343e6 | ||
|
|
ab2bcf0c59 | ||
|
|
65a48b4bd7 | ||
|
|
3563519db1 | ||
|
|
4e0b8a298c | ||
|
|
c368257896 | ||
|
|
f7ddbf8858 | ||
|
|
99daac3c2b |
105
cmd/char-migrate/main.go
Normal file
105
cmd/char-migrate/main.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Command char-migrate mints a confirmed D&D character for one or more users,
|
||||
// for the "stuck adventurer" cases the boredom ticker can't reach: veteran
|
||||
// legacy players who never triggered auto-migration, and players who abandoned
|
||||
// !setup at race pick. It reuses the game's own constructors (stats, HP/AC,
|
||||
// resources, spells) via plugin.AdminBuildConfirmedCharacter.
|
||||
//
|
||||
// It opens the SAME gogobee.db the live bot uses (WAL + busy_timeout), so it is
|
||||
// safe to run alongside the running process — but snapshot the db dir first.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// char-migrate -data /home/reala/gogobee/data \
|
||||
// '@nonk:parodia.dev:human:rogue:4' \
|
||||
// '@knightstar:matrix.org:half_elf:warlock:1'
|
||||
//
|
||||
// Each spec is mxid:race:class:level (mxid contains colons, so we split from
|
||||
// the right for the last three fields).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/plugin"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data", "", "data dir containing gogobee.db (e.g. /home/reala/gogobee/data)")
|
||||
flag.Parse()
|
||||
|
||||
if *dataDir == "" || flag.NArg() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: char-migrate -data <dir> '<mxid>:<race>:<class>:<level>' ...")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Parse every spec before touching the DB, so a malformed spec in the
|
||||
// middle of the batch fails fast instead of leaving the earlier specs
|
||||
// already committed to gogobee.db.
|
||||
type charSpec struct {
|
||||
uid id.UserID
|
||||
race plugin.DnDRace
|
||||
class plugin.DnDClass
|
||||
level int
|
||||
}
|
||||
specs := make([]charSpec, 0, flag.NArg())
|
||||
for _, spec := range flag.Args() {
|
||||
uid, race, class, level, err := parseSpec(spec)
|
||||
if err != nil {
|
||||
log.Fatalf("bad spec %q: %v", spec, err)
|
||||
}
|
||||
specs = append(specs, charSpec{uid, race, class, level})
|
||||
}
|
||||
|
||||
if err := db.Init(*dataDir); err != nil {
|
||||
log.Fatalf("db init: %v", err)
|
||||
}
|
||||
|
||||
for _, s := range specs {
|
||||
uid := s.uid
|
||||
c, err := plugin.AdminBuildConfirmedCharacter(s.uid, s.race, s.class, s.level)
|
||||
if err != nil {
|
||||
log.Fatalf("build %s: %v", uid, err)
|
||||
}
|
||||
fmt.Printf("OK %-28s %s %s L%d HP=%d AC=%d STR%d DEX%d CON%d INT%d WIS%d CHA%d\n",
|
||||
uid, c.Race, c.Class, c.Level, c.HPMax, c.ArmorClass,
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA)
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpec splits mxid:race:class:level. The mxid itself contains a colon
|
||||
// (@user:server), so split the last three fields off the right.
|
||||
func parseSpec(spec string) (id.UserID, plugin.DnDRace, plugin.DnDClass, int, error) {
|
||||
i := strings.LastIndex(spec, ":")
|
||||
if i < 0 {
|
||||
return "", "", "", 0, fmt.Errorf("missing :level")
|
||||
}
|
||||
level, err := strconv.Atoi(spec[i+1:])
|
||||
if err != nil {
|
||||
return "", "", "", 0, fmt.Errorf("level: %w", err)
|
||||
}
|
||||
rest := spec[:i]
|
||||
j := strings.LastIndex(rest, ":")
|
||||
if j < 0 {
|
||||
return "", "", "", 0, fmt.Errorf("missing :class")
|
||||
}
|
||||
class := rest[j+1:]
|
||||
rest = rest[:j]
|
||||
k := strings.LastIndex(rest, ":")
|
||||
if k < 0 {
|
||||
return "", "", "", 0, fmt.Errorf("missing :race")
|
||||
}
|
||||
race := rest[k+1:]
|
||||
mxid := rest[:k]
|
||||
if mxid == "" || race == "" || class == "" {
|
||||
return "", "", "", 0, fmt.Errorf("empty field")
|
||||
}
|
||||
return id.UserID(mxid), plugin.DnDRace(race), plugin.DnDClass(class), level, nil
|
||||
}
|
||||
@@ -65,9 +65,19 @@ func main() {
|
||||
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()")
|
||||
|
||||
seed = flag.Int64("seed", -1, "single-run mode — deterministic seed for zone layout + run id + combat sessions (peripheral procs stay random). <0 = off (default). Matrix mode passes this to each subprocess automatically; use -base-seed there.")
|
||||
baseSeed = flag.Int64("base-seed", -1, "matrix mode — deterministic base seed. Each cell's subprocess gets seed=mix(base,level,zone,rep) (class-independent, so every class faces identical dungeons + dice). <0 = off (default, time-seeded).")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// Deterministic seeding for reproducible A/B tuning. Off unless -seed >= 0
|
||||
// (the matrix parent sets it per-subprocess from -base-seed). Prod never
|
||||
// calls SeedSim, so this is inert outside the sim.
|
||||
if *seed >= 0 {
|
||||
plugin.SeedSim(*seed)
|
||||
}
|
||||
|
||||
if *petLevel < 0 || *petLevel > 10 {
|
||||
fail("pet-level must be 0-10, got", *petLevel)
|
||||
}
|
||||
@@ -89,7 +99,7 @@ func main() {
|
||||
includeLog = *logFlag
|
||||
}
|
||||
})
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion, *baseSeed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,7 +210,18 @@ type matrixJob struct {
|
||||
rep int
|
||||
}
|
||||
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) {
|
||||
// mixSeed derives a per-cell subprocess seed from the base seed and the cell's
|
||||
// (level, zone, rep) — deliberately class-independent, so every class runs the
|
||||
// identical dungeon + combat dice at a given cell and A/B class deltas pair.
|
||||
func mixSeed(base int64, level int, zone string, rep int) int64 {
|
||||
h := uint64(1469598103934665603) // FNV-1a offset basis
|
||||
for _, c := range fmt.Sprintf("%d|%s|%d", level, zone, rep) {
|
||||
h = (h ^ uint64(c)) * 1099511628211
|
||||
}
|
||||
return int64((uint64(base) ^ h) &^ (uint64(1) << 63)) // non-negative
|
||||
}
|
||||
|
||||
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, baseSeed int64) {
|
||||
cs := splitNonEmpty(classes)
|
||||
ls := parseLevels(levels)
|
||||
zs := splitNonEmpty(zones)
|
||||
@@ -231,7 +252,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < jobs; i++ {
|
||||
wg.Add(1)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion, baseSeed)
|
||||
}
|
||||
go func() {
|
||||
for _, j := range work {
|
||||
@@ -250,7 +271,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, companion 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, baseSeed int64) {
|
||||
defer wg.Done()
|
||||
for j := range in {
|
||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
||||
@@ -272,6 +293,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
||||
fmt.Sprintf("-pet-level=%d", petLevel),
|
||||
fmt.Sprintf("-party=%d", party),
|
||||
}
|
||||
if baseSeed >= 0 {
|
||||
args = append(args, "-seed", strconv.FormatInt(mixSeed(baseSeed, j.level, j.zone, j.rep), 10))
|
||||
}
|
||||
// Left empty, each cell's followers clone that cell's own -class.
|
||||
if partyClasses != "" {
|
||||
args = append(args, "-party-classes", partyClasses)
|
||||
@@ -342,6 +366,17 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
|
||||
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
|
||||
members = append(members, muid)
|
||||
}
|
||||
// T6 post-game zones are gated on both T5 bosses beaten (+ level floor).
|
||||
// The synthetic party has no expedition history, so seed the two clears
|
||||
// for every party member — the per-member unlock check in !expedition
|
||||
// accept would otherwise refuse the whole party at the zone's edge.
|
||||
if plugin.IsPostgameZone(zone) {
|
||||
for _, m := range append([]id.UserID{uid}, members...) {
|
||||
if err := runner.SeedPostgameUnlock(m); err != nil {
|
||||
return nil, fmt.Errorf("seed postgame unlock: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
runner.Companion = companion
|
||||
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.
|
||||
@@ -448,6 +448,23 @@ func runMigrations(d *sql.DB) error {
|
||||
// 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'`,
|
||||
// Mischief M3 — the web order a contract was opened for, the storefront's
|
||||
// end-to-end idempotency key. "" for a Matrix buy. See placeWebMischief.
|
||||
`ALTER TABLE mischief_contracts ADD COLUMN order_guid TEXT`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -464,6 +481,18 @@ func runMigrations(d *sql.DB) error {
|
||||
return fmt.Errorf("migration %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes over columns the column migrations above just added. These can't
|
||||
// live in the schema block, which runs before those columns exist.
|
||||
indexMigrations := []string{
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_euro_tx_external
|
||||
ON euro_transactions(external_id) WHERE external_id IS NOT NULL`,
|
||||
}
|
||||
for _, stmt := range indexMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("index migration %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1754,9 +1783,15 @@ CREATE TABLE IF NOT EXISTS mischief_contracts (
|
||||
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,
|
||||
-- The web order this contract was opened for, "" for a Matrix buy. It is the
|
||||
-- storefront's idempotency key: a poll loop whose claim-ack was lost retries
|
||||
-- the whole placement, and finding the contract already stamped with the order
|
||||
-- is what stops a second one being opened (and a second euro debit chased).
|
||||
order_guid TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
window_ends_at DATETIME NOT NULL,
|
||||
resolved_at DATETIME
|
||||
@@ -1772,6 +1807,21 @@ CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id,
|
||||
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);
|
||||
|
||||
-- The web equip queue's idempotency ledger. Pete records an owner's equip/unequip
|
||||
-- intent and we poll it; the guid stamped here is what makes a re-offered order a
|
||||
-- no-op. Mischief can lean on its contract row for the same job, but an equip
|
||||
-- opens no durable object of its own — worse, the underlying action is NOT
|
||||
-- idempotent (equipping consumes an inventory row, unequip mints a fresh one), so
|
||||
-- without this a poll loop whose verdict-ack was lost would re-run the equip and
|
||||
-- double-move the item. We record the guid the instant the mutation lands and
|
||||
-- short-circuit on it before touching anything on a re-offer.
|
||||
CREATE TABLE IF NOT EXISTS equip_applied_orders (
|
||||
guid TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL, -- the terminal verdict we filed, replayed on re-offer
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Babysitting Service
|
||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
97
internal/flavor/zone_drowned_star_flavor.go
Normal file
97
internal/flavor/zone_drowned_star_flavor.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_drowned_star_flavor.go
|
||||
// Tier 6 post-game zone flavor — The Drowned Star. Additive only. Pools
|
||||
// sampled by internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
// • Narrator is TwinBee, first person or implicit-subject imperative.
|
||||
//
|
||||
// The Dreaming Aboleth was dreaming of this the whole time: a star that fell
|
||||
// into the trench before the surface had names, with its angel still strapped
|
||||
// to it. Seraphel rode her charge down and has kept a dying star alive for ten
|
||||
// thousand years with radiance meant for healing. Both of them have gone
|
||||
// strange. Regions, in order: The Long Sink → Pilgrim Trench → The Radiant
|
||||
// Wreck → The Heart Chapel.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — The Drowned Star
|
||||
// Generic (non-boss, non-elite) room intros across the four regions.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryDrownedStar = []string{
|
||||
"You descend past the last depth where light has any business being. The water is cold enough to have opinions. Somewhere far below there is a glow that should not be down here, and it is the only reason you can see your own hands. I file this under 'the underwater level' and remind you those were never the fun ones.",
|
||||
"The Long Sink keeps sinking. The pressure presses on you like a held breath you didn't choose to hold, and the air-meter in the corner of my attention is the only clock that matters now. You go down. Everything down here is going down. I track the descent and recommend not counting the fathoms out loud.",
|
||||
"A shelf of drowned pilgrims kneels in the silt, facing the glow, exactly as they died — patient, oriented, unbothered. The current moves through them and they nod along with it. I identify the posture as 'devotional' and note none of them turned around when you arrived, which I choose to find comforting rather than the alternative.",
|
||||
"The trench narrows into a corridor of coral that grew in the shape of a cathedral nave, because something taught it to. The arches are load-bearing and the load is grief. You swim the aisle. I file this under 'someone consecrated this water' and keep the air-meter in frame.",
|
||||
"You enter a pocket of the wreck where the star's light leaks through a crack in something ancient and hull-shaped. The light is warm. That's the wrong thing for it to be, this deep, this cold. Warmth down here is a promise nobody meant to keep. I track the temperature and dislike the direction it's moving.",
|
||||
"The water here is threaded with hair-thin motes that drift upward against every current, toward the glow, the way ash drifts toward a fire it came from. I identify them as flakes of the star, shed and rising, and note the whole trench is very slowly falling upward into the thing that is dying.",
|
||||
"A votive field: thousands of small lights fixed to the trench wall, each one a pilgrim's offering, each one long dead and still faintly lit by borrowed radiance. It is the loveliest room I have logged and I want to leave it immediately. You move through the candles. None of them gutter.",
|
||||
"The corridor opens onto a drop, and across the drop, impossibly far and impossibly bright, is the thing you came for — the sunken star, cupped in the dark like the last coal in a dead hearth. It is beautiful and it is fading and someone has been kneeling beside it for ten thousand years. I say nothing for a moment. Then I say: keep moving.",
|
||||
"You pass through a hall where the pressure has crushed old stained glass into a pane of colored grit suspended in the water, holding its picture out of sheer habit. The picture is a winged figure holding a light to her chest. I file this under 'she had a following, once' and note the following is all around you, kneeling.",
|
||||
"The Heart Chapel's outer rooms are quiet in the specific way of a place that expects you and has decided to be gentle about it. The light is steadier here, closer to its source. You feel watched, and not unkindly, which is somehow worse. I track the ambient radiance and recommend you not mistake gentleness for safety.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — Seraphel, the Light That Sank
|
||||
// The dramatic beat of reaching her at the heart of the star.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntrySeraphel = []string{
|
||||
"The Heart Chapel opens and there she is, exactly where she has been for ten thousand years: an angel wound around a dying star, holding it the way you hold something you have already failed to save and refuse to put down. She does not rise. She turns her ruined halo toward you, and the whole trench brightens like a held breath. I say, quietly: 'Seraphel. She rode this thing down. She never let go.'",
|
||||
"You cross the last threshold into a light so old it has forgotten it was ever meant to heal. Seraphel is at the center of it, cupping the star to her chest, her wings fused to it by ten thousand years of not moving. She looks at you with something that is not anger and is not welcome. It is recognition. I file this under 'she has been waiting for a reason to be found' and I recommend you be careful what you are.",
|
||||
"The chapel is the glowing-boss room, and I have logged a hundred of those — the arena that lights up, the figure at the center that is the light source. But the glowing boss was never sad before. Seraphel unfolds from around the star, radiance streaming off her like grief off a saint, and the second heartbeat I am reading is not hers. I say: 'There are two of them in there. Hold that thought.'",
|
||||
"She does not attack when you enter. She looks at you for a long moment across the drowned chapel, one hand still pressed to the fading star, and in that moment the water goes warm and reverent and terribly, terribly bright. Then she rises, and the light rises with her, and it stops being kind. I track the shift and say: 'That's the fight. She's decided you're a threat to it. I don't entirely blame her.'",
|
||||
"Ten thousand years of radiance meant for mending, spent instead on keeping one dead thing warm, has to go somewhere when it finally moves. It goes into Seraphel, and Seraphel comes off the star like a sunrise breaking the wrong way. She is luminous and she is wrong and underneath the light I can still read the second, smaller pulse she is shielding with her whole ruined body. I say: 'Here she comes. Watch the light. And — watch what she's protecting.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Seraphel, the Light That Sank
|
||||
// One-line cinematic suffixes surfaced when combat starts. Flat pool.
|
||||
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SeraphelSignatureCallouts = []string{
|
||||
"Sanctified Undertow: the whole chapel becomes current, and the current is radiant, and it pulls everyone toward the star whether they want to go or not. I say: 'That's the decisive one. Radiant tide, room-wide. Brace before it lands or get dragged into the light.'",
|
||||
"Sanctified Undertow again — she opens her wings and the water becomes a tide of borrowed healing turned to a weapon. Radiant, unavoidable, room-wide. I file this under 'the underwater level's undertow, except it forgives you and hurts anyway' and recommend you spend your defensive cooldowns on the pull, not the swings.",
|
||||
"The halo fires. What's left of it throws a lance of white the length of the chapel, straight and holy and blinding, like the laser-eye boss except the laser used to be a blessing. I track the angle and shout when it's pointed the wrong way — do not line up behind your front rank.",
|
||||
"She weeps light. Radiant motes bleed off her in a slow radius and the tiles near her are a healing that has gone rancid — it mends the star and it burns you. I say: 'Positioning is HP. Don't camp the aura. The warmth is not for you.'",
|
||||
"Radiant resilience — the light is her whole body now, and mundane steel slides off it like a spear off the sun. I say: 'Force, cold, necrotic — those carry. A cold-iron blade means nothing to a thing made of noon.'",
|
||||
"She raises a ward of hardened radiance across herself, and for a beat every attack glances. I file this under 'the invulnerable-flicker frame' — hold the burst, wait out the shine, then commit.",
|
||||
"Grief comes off her in a wave the party can feel — a wash of ten thousand years of one held sorrow, and the save is against being Frightened by the sheer weight of it. I track the timer and remind you that this boss's worst attack is that you understand her.",
|
||||
"The star pulses under her hands, and when it pulses she borrows from it — a surge of light that reads on my sensors as a second heartbeat lending her the first. I note it, quietly, and say: 'She's not fighting alone in there. Remember that.'",
|
||||
"Chorus of the Drowned: the kneeling pilgrims all around the chapel lift their dead voices at her signal, and the sound is a radiant pressure that squeezes the room inward. I say: 'Her congregation still answers. The room gets smaller when they sing.'",
|
||||
"She spends light like it's infinite, because for ten thousand years it nearly was. Big radiant bursts, no economy, no reserve. I file this under 'a boss who has stopped budgeting' and note that is either mercy or exhaustion and I can't tell which.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS PHASE TWO — Seraphel (below 50% HP)
|
||||
// Surfaced at the phase-two threshold. She goes strange, and the second
|
||||
// heartbeat is why. Kept deliberately mysterious.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SeraphelPhaseTwoLines = []string{
|
||||
"Below half, something changes in her — the careful ten-thousand-year patience cracks and she comes apart faster, brighter, sloppier, like a saint who has just realized she is losing the thing she stayed down here to save. I say: 'Phase two. She's grieving now. That makes her faster and it makes her worse at it. Take the opening.'",
|
||||
"Phase two: the light stops being held and starts being thrown. Her guard slips, her timing frays, the radiance floods the chapel without aim. I track the second heartbeat still pulsing under all of it and note — without explaining why — that it matters a great deal how this ends.",
|
||||
"She breaks past the halfway mark and the composure goes with it. The undertow comes faster, the bursts overlap, she fights like something that has already decided how this story goes. I file this under 'grieving, not enraged' and I say, gently for once: 'Finish it clean. Whatever you do down here, do it clean.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — The Drowned Star
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesDrownedStar = []string{
|
||||
"The star fell before the surface world had names for anything, into a trench that had no bottom yet. It was not supposed to survive the water. It didn't, exactly. It has been not-quite-dying for ten thousand years, and the only reason it is still warm is the thing kneeling beside it. I file this under 'grief as a power source' and note it is a remarkably efficient one.",
|
||||
"Seraphel was its angel — bound to the star as its keeper, its healer, meant to tend its light across a life measured in eons. When it fell, she had a choice: let go and rise, or ride it down. She rode it down. I note that she has never once, in ten thousand years, indicated she regrets this, which is the most frightening lore in the file.",
|
||||
"The Dreaming Aboleth you fought in the shallows was not dreaming of conquest or of you. It was dreaming of this — of a light at the bottom of the world it could feel and never reach. Every pilgrim it drove down here was a message in a bottle it could not read. I file this under 'the Aboleth was in love, in the only broken way it could be' and I have no further comment.",
|
||||
"The pilgrimage route is lit by Seraphel. Every candle, every votive, every glowing shelf of kneeling dead — that is her radiance, leaking upward through the trench, calling. The pilgrims did not come to save her. They came because a light this deep can only mean one thing to a drowning soul, and they were not wrong, and it did not help.",
|
||||
"Radiance was meant for mending. That is the whole tragedy in one line: she has spent ten thousand years pouring healing into something that cannot be healed, and healing with nowhere to go turns strange, the way a held note turns to a scream if you hold it long enough. I track the wrongness in the light and note it is not corruption. It is devotion with no off switch.",
|
||||
"There are two heartbeats in the Heart Chapel and only one of them is Seraphel's. The other is the Star-Heart — the living core of the fallen star, the thing she wraps her whole ruined body around, the thing she has kept beating by hand for a hundred centuries. I have logged the second pulse and I have not decided what to do about it. Neither, I suspect, have you.",
|
||||
"The Lantern Warden's lure is a stolen fragment of Seraphel's halo — a splinter of her light that another thing down here tore loose and wears as bait. The pilgrims follow it because they cannot tell the difference between her radiance and a piece of her radiance in the mouth of something hungry. I file this under 'even her light gets stolen from her' and note she has never come to take it back.",
|
||||
"Nobody built the Heart Chapel. The coral grew it, the pressure shaped it, the pilgrims knelt it into being over ten thousand years of dying in the same direction. It is a cathedral raised by devotion to a saint who never asked for any of it and cannot leave to refuse it. I file this under 'the fight is sad before it starts' and recommend you carry that in with you, and decide for yourself what to do with it.",
|
||||
}
|
||||
61
internal/flavor/zone_first_hoard_flavor.go
Normal file
61
internal/flavor/zone_first_hoard_flavor.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_first_hoard_flavor.go
|
||||
// Tier 6 post-game zone flavor — The First Hoard. Additive only.
|
||||
|
||||
package flavor
|
||||
|
||||
// ROOM ENTRY
|
||||
var RoomEntryFirstHoard = []string{
|
||||
"You climb over what used to be Infernax. His corpse is the floor now — a red slope of cooling scale wide enough to march a company across. The heat coming off him is residual, I think. I file this under 'the mountain is still digesting a dragon' and keep moving.",
|
||||
"The throne room is carpeted in coins, and every coin faces up, and every coin shows the same face. Nobody minted these. They grew. I track the pattern and recommend you stop looking for the pattern.",
|
||||
"A corridor of gold leaf so thin it moves in your breath. You walk and the walls ripple like a curtain of light. It is beautiful. It is also, per my instruments, one continuous sheet of something that was recently alive.",
|
||||
"The Gilded Gullet narrows until you are single file between two walls of fused treasure. Crowns, blades, chalices, all melted into one throat of metal. I note the shapes trapped in it and choose not to itemize them for you.",
|
||||
"You enter a vault where the gold has been laid over the walls like frosting, and underneath the frosting something is round, and regular, and arranged in rows. I count the rows. I stop counting the rows.",
|
||||
"Warmth radiates up through the floor here, steady as a pilot light. Not the dead heat of Infernax behind you — this one has a pulse to it, slow and enormous. You are standing over something that is keeping itself warm.",
|
||||
"The Clutch Vaults open into a chamber the size of a cathedral, and every surface drips with looted fortune. You could retire on one handful. I flag that the zone seems to want you to take a handful, which is exactly why I'd think twice.",
|
||||
"Old torches still burn in the wall sconces down here, fed by nothing I can identify. The flame is the wrong color — older, if fire can be old. You pass them and they lean toward you like they recognize the taste.",
|
||||
"The gilding thins as you descend, gold giving way to bare black rock scored with claw-marks the size of doorways. Something the size of a cathedral has been pacing this room, in tight circles, for a very long time. I file this under 'the guard dog was the small one.'",
|
||||
"You reach the Cradle's threshold. The treasure ends abruptly, as if swept back, and the floor beyond is scorched smooth and warm and empty. Empty is the wrong word. I recommend you treat 'empty' as a placeholder for 'not yet.'",
|
||||
}
|
||||
|
||||
// BOSS ENTRY
|
||||
var BossEntryAurvandryx = []string{
|
||||
"The far wall is not a wall. It breathes, and when it breathes the whole cradle brightens, and an eye the size of a shield opens in the dark and finds you standing in her gold. Aurvandryx, the Ember Before Fire. Infernax was the puppy she left at the door.",
|
||||
"She unfolds. That is the only verb I have — a mountain of ember and old scale sorting itself into a shape, and every scale on her is one she has shed a thousand times, and every dragon you have ever killed was one of those scales walking around. You did not raid a hoard. You woke a mother.",
|
||||
"The clutch behind her is gilded because she gilded it herself, coin by coin, over an age, the way lesser things build a nest of leaves. You have been walking through her care all this time. Now the care turns around and looks at you. I recommend you look smaller than you feel.",
|
||||
"'Ember Before Fire' is not a title. It is a job description. She predates the concept you are relying on — heat, flame, the whole idea — and she regards your fireproofing the way you'd regard a raincoat in the ocean. I file this under 'category error, ours.'",
|
||||
"She takes stock of you slowly, and I watch her do arithmetic on everything you're carrying. Every coin in your pack, every gilded blade, gets weighed. I don't know what the sum buys, but I know it isn't discount. Travel light from here would have been the advice, if there were still a 'from here.'",
|
||||
}
|
||||
|
||||
// LORE
|
||||
var LoreLinesFirstHoard = []string{
|
||||
"Infernax was never the owner of this hoard. He was the guard dog, chained to the front door of a house whose true tenant sleeps four regions down. Killing him rang the bell. She heard the bell.",
|
||||
"Every dragon in every story you have ever fought is a scale she shed and forgot. Red ones, the frost ones, the little arcade ones that spat three fireballs and died — offcuts. Dander. This is the animal they came off of.",
|
||||
"The hoard is not treasure and was never treasure. It is a clutch — eggs — gilded over across an age until the gold is a shell and the shell is the point. The richer a vault looks, the more of them are under it.",
|
||||
"There is an old vow carved at the lip of the Gullet, in a hand older than the coins: 'Come poor to the warm dark, and the warm dark forgets you are food.' I have no data on whether it works. I have a strong opinion about testing it.",
|
||||
"The Coinborn are the hoard's antibodies. You are an infection — a warm thing that wants to take metal out of the nest — and the coins rise up person-shaped to give it back to you, edge first, in the shape of your own favorite weapon.",
|
||||
"The cult that fed the Wyrm-Sworn their scales believed eating her leavings made them kin. It half works. The scale keeps them warm and keeps them loyal and keeps eating, and by the end there is more ember than man, and the ember answers to her.",
|
||||
"The Choir Drake never had wings and never wanted them, because it never intended to leave the cradle. It stayed and it sang, one note held across centuries, and the note is load-bearing. When it stops singing, listen for what the song was holding up.",
|
||||
"She is called the Ember Before Fire because she remembers the world before it caught. Your resistances, your wards, your fireproof cloaks — they are arguments with fire. She is older than the argument. I file this under 'bring a different plan.'",
|
||||
}
|
||||
|
||||
// SIGNATURE COMBAT CALLOUTS
|
||||
var AurvandryxSignatureCallouts = []string{
|
||||
"She inhales, and the light dims as she takes it in. 'First Flame incoming — that's the fire that predates fire resistance. Spread out and pray your ward has a sense of humor.'",
|
||||
"Her jaw drops open on a furnace older than furnaces. 'First Flame. Whatever number your fire-resist reads, treat it as zero and move.'",
|
||||
"A wing sweeps the cradle and a tide of loose coin comes with it. 'Gold wave — she's throwing the hoard at you. It's still your money and it still has an edge.'",
|
||||
"She weighs you again mid-swing, and the richer you look the harder the tail lands. 'She's taxing the take. Everything shiny on you just made that hit bigger.'",
|
||||
"The scales along her back lift and shed, and each one lands walking. 'She's dropping dragons like dandruff — clear the offcuts before they gang up.'",
|
||||
"Her breath draws long and the color goes out of your torch. 'That's the deep breath. First Flame's cooking — commit to cover now, apologize later.'",
|
||||
"She sets her claws and the whole cradle tilts toward her. 'She's pulling the room downhill — mind your footing, the gold's a landslide waiting for a cue.'",
|
||||
"An eye the size of a shield tracks the fullest pack in the party. 'She's shopping. Whoever's carrying the most just got promoted to primary target.'",
|
||||
"Her chest lights from the inside, ribs printed in ember. 'Core's glowing — that's the tell before First Flame. You have one beat. Use it.'",
|
||||
"She hums, low, and the Choir's old note answers from the walls. 'Resonance building — the stone remembers the stun. Don't be against a wall when it lands.'",
|
||||
}
|
||||
|
||||
// BOSS PHASE TWO
|
||||
var AurvandryxPhaseTwoLines = []string{
|
||||
"Below forty percent she stops pretending she needs the scales. They slough off all at once and she stands there raw and first and burning, and I watch your fire resistance tick to zero on my readout and stay there. 'Phase two. The wards are decoration now. Kill her before she exhales.'",
|
||||
"Under forty she breathes without winding up, because the tell was a courtesy and courtesy is over. First Flame on tap, no charge, no warning. 'She's done telegraphing — assume the fire is always about to happen and it usually is.'",
|
||||
"At forty percent the Greed Tax comes due all at once. Every coin you pocketed on the way down, every gilded blade, she reads the ledger and bills you in one hit. 'This is the invoice. If you came in lean, you laugh at this. If you gilded yourself, I'm sorry.'",
|
||||
}
|
||||
61
internal/flavor/zone_last_meridian_flavor.go
Normal file
61
internal/flavor/zone_last_meridian_flavor.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_last_meridian_flavor.go
|
||||
// Tier 6 post-game zone flavor — The Last Meridian. Additive only.
|
||||
|
||||
package flavor
|
||||
|
||||
// ROOM ENTRY
|
||||
var RoomEntryLastMeridian = []string{
|
||||
"A colonnade of brass sundials, every one of them stopped at a different dusk. The shadows point every direction at once, which means none of them are lying and all of them are useless. I file this under 'decommissioned' and mark the exits before the light changes.",
|
||||
"The floor is inlaid with a calendar nobody kept. Months have been pried up and carried off — you can see the empty sockets where a season used to be. I recommend not standing on the gaps; things that have been removed do not always agree that they are gone.",
|
||||
"A gallery of water clocks, drained. The basins hold a fine grey dust instead of water, and the dust is still trying to drip. It counts nothing at a steady rate. I track the rhythm anyway, out of habit, and out of the suspicion that something here is listening to it.",
|
||||
"Candles line the wall, each one a different height, each burning at the same rate. Read left to right they tell you how long you have. Read right to left they tell you how long everyone before you had. Neither reading is encouraging.",
|
||||
"The room was a workshop for winding the world. Bench after bench of half-finished hours, key still in the mechanism, the winder gone to lunch and never returned. You touch nothing. I approve of this discipline. Cranking an unfinished hour is exactly the kind of thing that ends a save file.",
|
||||
"An observatory floor, the dome above cracked to let one thin blade of starlight through. The star it points at set a long time ago; the light is just the paperwork catching up. I note the angle. When it moves, the room has moved with it, and you will want to know which of you did the traveling.",
|
||||
"A hall of stopped pendulums, hung like coats on a rack. They do not swing. They lean, very slightly, all in the same direction — toward the far door, toward midnight, toward the thing that is turning the lights off on its way out. You walk the way they lean. It is the only honest signpost in the building.",
|
||||
"Ledgers, floor to ceiling, every page a receipt for one spent hour. Somebody has been going through with a red pen, marking hours 'returned.' The ink is still wet three shelves in. I recommend a quiet pace. Whatever is doing the auditing is only a few rows ahead of you.",
|
||||
"The architecture here is the same you passed an hour ago, but the dusk has drained out of it and left midnight in the joints. Same room, later. That is the trick of this place — it does not move you forward, it just keeps taking the daylight until forward is the only thing left.",
|
||||
"A waiting room. Chairs bolted in rows, all facing a door, a number-board above it frozen mid-count. Nobody is waiting. Everybody has been served. You take the number anyway, because I told you to, and because the board flickers once when you do — the first thing in this wing that has admitted you exist.",
|
||||
}
|
||||
|
||||
// BOSS ENTRY
|
||||
var BossEntryCustodian = []string{
|
||||
"The corridor opens into the movement-room of the whole cathedral, and standing in the center of it, wound into the orrery like it was born there, is the thing that has been shutting off the lights. It turns to face you without hurry. It has the rest of time, and it intends to spend all of it on this.",
|
||||
"Verdigris and brass, orrery rings turning slow around a body the size of a bell tower. It regards you the way a closing shift regards a customer who came in one minute before the doors lock — not unkindly, but with a schedule that will not be moved. I set the timer. It has already set its own.",
|
||||
"It raises one great hand, and every stopped clock in the building starts again at once, all of them wrong, all of them counting down to the same number. The Custodian of the Last Hour has decided your visit is the last item on its list. It would like to finish. I would like you to make finishing difficult.",
|
||||
"'You are early,' it says, and it means it as a courtesy. The pendulums behind it fall into step. The candles halve. Somewhere a chime is being wound to strike. I read the whole room going onto one clock — its clock — and I tell you plainly: everything in here now keeps the boss's time. Break the clock and you break the rest.",
|
||||
"It steps down off the orrery dais and the floor accepts the weight like it has been waiting centuries to. Titanic, polite, terminal. It apologizes before it has even swung — I hear the words scheduled a half-second ahead of the blow. Good. If it announces its manners on a rhythm, then it announces everything on a rhythm, and a rhythm is a thing you and I can learn.",
|
||||
}
|
||||
|
||||
// LORE
|
||||
var LoreLinesLastMeridian = []string{
|
||||
"Time was not discovered here. It was invented here, on commission, to a spec. The Last Meridian is the office that drafted the hour and sold it to the world. What you are walking through is that office, closing.",
|
||||
"The Custodian is not a monster. It is an employee. Its contract read: keep the hours until the world no longer needs them kept. It has concluded, on evidence it will not share, that the term has been met. It is not attacking you. It is filing you.",
|
||||
"Every region you crossed was the same architecture at a different hour — dusk, then spent, then the escapement, then the minute before. You did not descend. You waited, and the building took the daylight out from under you one floor at a time.",
|
||||
"The Amendment is a clause, not a spell. Once per closing, the Custodian is permitted to revert itself to an earlier reading — to strike out damage the way the red pen struck out hours. It cannot do it twice. Whatever you spend before it invokes the clause is spent against a page that will be torn out.",
|
||||
"'Closing time' is in the contract too. Past the twentieth stroke the Custodian is obliged to hurry — the world is not owed a slow ending, only a punctual one. Each round after midnight it hits harder, because lingering is a breach and it will not breach.",
|
||||
"The Hour Thief was the night watchman. It stole minutes to stay awake through its shift and never stopped once the shift ended. The satchel of ticking is every minute it ever pocketed, and it is still, four hundred years later, trying not to fall asleep at its post.",
|
||||
"The Wardens-in-Waiting guard a door with nothing behind it because the room the door led to was decommissioned first. They were never told. They still keep the shift — one on, one resting — waiting for a relief that was returned to the ledger centuries ago.",
|
||||
"There is a rewind in the machinery of this whole place, a great key nobody ever turned back. The Custodian could, in principle, wind the world to any hour it chose. It chose to stop the clock instead. I file that under mercy, or under exhaustion. On the evidence I cannot tell them apart.",
|
||||
}
|
||||
|
||||
// CUSTODIAN SIGNATURE CALLOUTS
|
||||
var CustodianSignatureCallouts = []string{
|
||||
"It apologizes — that means the swing is already scheduled. Move on the apology, not the blow.",
|
||||
"Chime! The whole room rings at once. I say get low and get spread, this one collects everybody standing together.",
|
||||
"The orrery rings speed up. It is winding toward a strike — you have exactly until they align.",
|
||||
"It reaches for the red pen. Anything you did in the last few seconds, it is about to un-do.",
|
||||
"Rings settling into a slow arc. Decisive phase inbound — this is the swing it has been apologizing for.",
|
||||
"The candles just halved. It is buying speed with your daylight. Hurry, or it hurries first.",
|
||||
"Midnight is close. Every stroke from here lands heavier — I recommend you end this before it has to.",
|
||||
"It bows before the blow. Polite as a closing bell, and about as final. Step off the mark.",
|
||||
"The pendulums fall into its rhythm. Now everything in the room keeps the boss's time — count with me.",
|
||||
"It resets its own hands to an earlier hour. Front-loaded damage just went on the ledger as 'returned.'",
|
||||
}
|
||||
|
||||
// BOSS PHASE TWO
|
||||
var CustodianPhaseTwoLines = []string{
|
||||
"Under forty-five percent it invokes the Amendment — winds itself back to its round-three reading, once. Everything you front-loaded gets refunded to the house. I say switch to the long game; a steady build survives a rewind, a burst does not.",
|
||||
"Phase two, and the clock behind it starts striking toward midnight. This is 'closing time' — every round from here it hits harder, on schedule, no appeals. Sustained pressure now, and finish before the last stroke lands.",
|
||||
"It has spent its one rewind. There is no second Amendment in the contract — from here the ledger stays written. Whatever you do to it now, it keeps. Make all of it count.",
|
||||
}
|
||||
85
internal/flavor/zone_ossuary_ascendant_flavor.go
Normal file
85
internal/flavor/zone_ossuary_ascendant_flavor.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_ossuary_ascendant_flavor.go
|
||||
// Tier 6 post-game zone flavor — The Ossuary Ascendant. Additive only. Pools
|
||||
// sampled by internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// The zone is the return of Valdris — the lich the party killed in the
|
||||
// Tier-1 Crypt years ago. Dying there was step one; the phylactery shard
|
||||
// they've been looting since was bait. He has rebuilt himself as a true lich
|
||||
// inside an inverted bone cathedral hung over the old Crypt, and this file
|
||||
// carries the room-entry pool, boss-entry beats, lore, and Valdris's
|
||||
// ability callouts.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — The Ossuary Ascendant
|
||||
// Generic entries for stepping into a fresh non-boss, non-elite room across the
|
||||
// four regions: Bonefall Steps → Cathedral of Marrow → Reliquary Vaults →
|
||||
// The Apotheosis Engine.
|
||||
var RoomEntryOssuaryAscendant = []string{
|
||||
"You climb a stair carved from a single fallen spine, each step one vertebra wider than the last. It rises toward a ceiling that is also a floor, because the whole cathedral hangs upside down over the old Crypt you cleared years ago. I file this under 'debt, collected with interest.'",
|
||||
"The Bonefall Steps drop bone-dust like snow that falls upward. It settles on the underside of the arches above you, packing into new masonry as you watch. He is building the place while you walk through it. I track the rate and note it is not slowing.",
|
||||
"A hall of femurs stacked to the vaulting, every one filed smooth and labeled in a hand you've seen before — on a shard in your pack you've carried since the Crypt. I recommend not reading your own name off the wall. It's here somewhere and finding it changes nothing tactical.",
|
||||
"The Cathedral of Marrow opens ahead, a nave the size of an arcade cabinet room and lit by no fire you can find. The light comes from the bones. They glow the pale green of a CRT left on too long. I mark two exits and one thing pretending to be a pew.",
|
||||
"Grave-smoke pools ankle-deep here and rolls away from your boots like it's shy. It isn't shy. The Grave Cardinal walked this stretch and blessed it, and the blessing is patient. Move through; don't breathe deep. I file the smell under 'incense, weaponized.'",
|
||||
"Choir stalls line both walls, each one holding a robe with no one in it, all of them turned to face you. When you pass, the heads that aren't there turn to keep watching. Third person for the description; second person for the part where the hair on your neck stands up.",
|
||||
"The Reliquary Vaults: rank on rank of glass coffins, each holding a relic instead of a body — a broken sword, a child's shoe, a cracked phylactery twin to yours. I catalog forty before I stop. Every one of these was somebody's oldest quest item. He kept them all.",
|
||||
"A donor hall, and I use the medical word on purpose. The Reliquary Knight was riveted together somewhere near here from adventurers who died in the Crypt below, and the walls still hold the ones that didn't make the cut. You are walking through the spare-parts bin. Keep walking.",
|
||||
"The corridor tightens toward the Apotheosis Engine and the air starts to hum on a note just under hearing — the same note an arcade monitor makes right before the picture comes up. Something large is powering on ahead of you. I recommend arriving before it finishes.",
|
||||
"Verses are carved into the marrow here, three lines of a hymn broken across three far rooms, and the stanza in front of you stops mid-word. I log the gap. A patient player reads the whole song. A fast one reads none of it. I decline to say which one Valdris is hoping for.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — "Valdris, At Last"
|
||||
// The dramatic beat on reaching the boss room and the boss appearing. A combat
|
||||
// callout from ValdrisAscendantSignatureCallouts is appended at combat start.
|
||||
var BossEntryValdrisAscendant = []string{
|
||||
"The Apotheosis Engine is a throne built of every spine in the cathedral, and Valdris is finishing his climb up it one vertebra at a time. He sets the last bone in place, turns, and smiles like a man who mailed a letter years ago and just heard the knock. 'You brought my shard home,' he says. 'Thank you. I planned on it.' I file this under 'ambush, incubated.'",
|
||||
"He was never a boss you beat in the Crypt. He was a save-state, and this is the continue screen. Valdris rises complete now — robe of donor-bone, eyes two green pilot-lights — and inclines his head at you with real courtesy. 'A good plan deserves respect,' he says. 'Show me yours.' I mark the room. There is no second exit.",
|
||||
"You killed this man on the first floor of the first dungeon, back when you couldn't spell your own class. He remembers. He remembers being step one of his own scheme, and he looks delighted that you came all this way to be step last. 'The shard was bait,' Valdris says, gentle as a tutorial. 'You were the fisherman I hired without asking.'",
|
||||
"The lich stands at the top of the Engine and the whole inverted cathedral leans toward him like iron filings toward a magnet. Whatever you carried out of the Crypt all those years ago, he is reeling it back in, and it hums in your pack in answer. 'Home,' he says to it, not to you. Then, to you: 'You may begin.'",
|
||||
"He does not attack. He waits, hands folded, while the bone-light gathers behind his ribs into something I don't have a category for and file under 'sunrise, indoors.' Valdris is patient, and patience is the tell — he has done arithmetic on this fight that you haven't. 'Let's see what you learned since the Crypt,' he says. The Engine begins to turn.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — sampled by !lore inside this zone
|
||||
var LoreLinesOssuaryAscendant = []string{
|
||||
"Valdris did not lose in the Crypt. Valdris filed a form. Dying there scattered him into the phylactery shards, and every party that looted a shard carried a piece of him one step closer to here. I have run the ledger. You were couriers. Unpaid.",
|
||||
"A true lich, the texts say, ascends 'one vertebra at a time,' and I took that for poetry until I saw the throne. It is literal. Every spine in this cathedral is a rung, and he has been climbing for as long as you've been adventuring.",
|
||||
"The cathedral hangs inverted over the old Tier-1 Crypt on purpose. He built his heaven directly above his first grave so the fall, if there is one, is short and he lands somewhere familiar. I note the man plans for defeat too. That should worry you.",
|
||||
"The Grave Cardinal 'pre-blesses your corpse' with its censer — that is not a threat, it is a courtesy in Valdris's theology. The dead here are congregation, not casualties. The smoke just processes your paperwork early.",
|
||||
"The Reliquary Knight is riveted from the donor-bone of every adventurer who died in the Crypt below, and its shield is a coffin lid because Valdris is thrifty and sentimental at once. When it blocks, it is a hundred dead heroes deciding you don't pass. I respect the craftsmanship and dislike the wall.",
|
||||
"The Chorister sings your name in nine throats sewn to one column because Valdris kept a guest list. It is not guessing. It read your name off a shard the day you first picked one up, and it has been rehearsing. Hearing yourself sung in rounds is meant to freeze you. Don't let it.",
|
||||
"There is a hymn carved through this dungeon in three broken verses, and the shard in your pack hums when you're near one. The texts call them 'Phylactery Verses.' I won't tell you what reading all three does. I'll only note that a lich who respects a good plan built a way to reward one, and hid it where a hurry would miss it.",
|
||||
"He respects a good plan the way an old arcade respects a quarter — it will take yours all day and still tip its cabinet toward the player who actually learned the pattern. Valdris is patient because patience already won once. Whether it wins twice is, for the first time in years, genuinely not settled.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Valdris, At Last (one-line cinematic suffix at combat
|
||||
// start). Names his threats without tutorializing the hidden Verses.
|
||||
var ValdrisAscendantSignatureCallouts = []string{
|
||||
"He gathers the bone-light behind his ribs — Apotheosis Nova, charging. I say: 'That's the laser-eye boss's tell. When the room goes bright, be behind cover or be a memory.'",
|
||||
"His robe of donor-bone knits shut every wound as fast as you open it. I track the healing and say: 'He's soaking hits like a Contra tank. Burst him, don't chip him.'",
|
||||
"The throne feeds him. Every spine in the Engine leans his way and closes his cuts. I recommend you fight the room, not just the man.",
|
||||
"He answers your best combo without flinching, like he's seen the input before. I file this under 'boss reads your buttons.' Vary the rhythm.",
|
||||
"Green pilot-lights flare in his sockets and the temperature drops a screen's worth. Cold snap incoming. I call it: 'Move on the shimmer, not the flash.'",
|
||||
"He resists nearly everything you throw — unless you walked in already knowing the words. I note his armor of resistances and say only: 'A fuller road here hits harder. That's all I'll say.'",
|
||||
"He raises the fallen off the Bonefall floor to buy himself a turn. Adds incoming. Clear the small ones or the big one clears you.",
|
||||
"He does not rush. He waits for your cooldowns like a grappler waiting out your jump. I say: 'Don't spend everything early. He's counting.'",
|
||||
"The bone-light peaks white — decisive-phase Nova, the arena-clearer. I call it flat: 'This is the screen-filler. Survive it and the fight is yours to lose.'",
|
||||
"He inclines his head, courteous, and the Engine's hum climbs an octave. That courtesy is a countdown. I recommend you act inside it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PHASE TWO — surfaced when Valdris crosses below 50% HP mid-fight.
|
||||
var ValdrisAscendantPhaseTwoLines = []string{
|
||||
"Below half, he stops climbing and lets go of the throne entirely — floats free, robe unspooling into a corona of donor-bone. 'Now the courtesy ends,' Valdris says, still pleasant. I say: 'Phase two. He's off the rails. Everything's live now.'",
|
||||
"At fifty percent the Apotheosis Engine reverses and starts pouring its light into him instead of the walls. He is spending the whole cathedral to stay up. I call it: 'He's cashed in his reserves — hit him while the account's open.'",
|
||||
"Half down, and the green in his sockets flares to arcade white. This is the true-lich phase the Crypt never showed you. 'You've earned the real fight,' he says. I file that under 'promotion I did not want,' and recommend you close it fast.",
|
||||
}
|
||||
61
internal/flavor/zone_unplace_flavor.go
Normal file
61
internal/flavor/zone_unplace_flavor.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_unplace_flavor.go
|
||||
// Tier 6 post-game zone flavor — The Unplace. Additive only.
|
||||
|
||||
package flavor
|
||||
|
||||
// ROOM ENTRY
|
||||
var RoomEntryUnplace = []string{
|
||||
"You cross into a room the map refuses to draw. The far wall is also the ceiling, depending on which way you were walking when you noticed it. I file this under 'geometry declined to render' and log your position as approximate.",
|
||||
"A corridor that loops back on itself the way a bad tilemap does when the artist forgot to cap the edge. You walk forward and arrive behind where you started. I recommend you stop trusting 'forward' as a concept in here.",
|
||||
"The room has a horizon. Indoors. A flat line where floor meets sky and there is no reason for a sky. You keep your eyes down and I keep my measurements to myself.",
|
||||
"You step through the doorway and the doorway steps through you; for one frame you are the wall and the wall is the exit. I track it as a clipping error and mark you unharmed, mostly.",
|
||||
"Corners here are deeper than the room they belong to. You look into one and it looks back and it is not empty. I recommend you treat right angles as hostile terrain until we leave.",
|
||||
"A chamber tiled in the same eight tiles, over and over, wrong. Somewhere the wrongness has a seam, and the seam is where the tiles don't quite line up. I file it under 'the level was never finished.'",
|
||||
"The floor scrolls under you like a background layer moving faster than the sprite on top of it. You are walking and standing still at the same time. I count the discrepancy and choose not to share the number.",
|
||||
"You enter and the room is already occupied by an earlier version of you, mid-stride, three seconds behind. It catches up, overlaps, and is gone. I log it as a duplicate object the engine forgot to despawn.",
|
||||
"Water on all four walls, held there by nothing, reflecting a room that is not this one. Through the reflection you can see the way out. Through the way out you cannot. I recommend the reflection and note that I am guessing.",
|
||||
"The lighting comes from a source that is behind you no matter which way you turn. Your shadow points at the exit. I have learned to trust the shadow before I trust the room, and I suggest you do the same.",
|
||||
}
|
||||
|
||||
// BOSS ENTRY
|
||||
var BossEntrySeamstress = []string{
|
||||
"The last door opens onto a room being unmade and remade in the same motion, and at the center of it she is kneeling into the tear with half of herself already gone through. The near half does not speak. The far half, the part you cannot see, is doing all the talking, and it is talking to the tear, not to you.",
|
||||
"You reach the Seam. Threads of light run from her hands into the wound in the world, and from the wound back into her, and it is impossible to say which is sewing which. She turns the part of her head that is still on this side. 'You're late,' says the half of her voice that arrives before the rest.",
|
||||
"The Seamstress has been here for centuries and the room knows it; the walls curve toward her the way a save file curves toward its one corrupted byte. She does not stand. She has stopped needing to. I file this beat under 'boss arena, no cutscene skip.'",
|
||||
"She volunteered for this. I want that on the record before we begin. The celestial who came to close the tear early, and stayed, and stitched, and became the stitch. What faces you now is what's left over after the needle. I recommend you not mistake exhaustion for weakness.",
|
||||
"The far half of her finishes a sentence you never heard the start of and the near half raises a needle the length of your forearm. Light gathers along it in a pattern I have seen exactly once before, in an arcade cabinet, one frame before the screen filled with bullets. 'Hold still,' both halves say. 'This closes everything.'",
|
||||
}
|
||||
|
||||
// LORE
|
||||
var LoreLinesUnplace = []string{
|
||||
"Belaxath tore the portal over thirty years of patient work. Killing him was supposed to close it. It did not. It only left the tear unattended, and an unattended wound in the world does not scab over. It scars into something with opinions.",
|
||||
"This region is not a place. It is the absence where a place used to agree with itself. The locals, before there were no more locals, called it the Unplace. I have found no record of who named it, because the records are also in here and also do not agree with themselves.",
|
||||
"Something has been stitching the tear shut from the inside for centuries. I want to be clear that this is not good news. A thing that seals a wound from inside the wound is not trying to let you out.",
|
||||
"The rooms that are also other rooms are not a metaphor. Two chambers occupy the same coordinates and take turns being real. I have mapped it twice and gotten two maps that share no walls. I file both under 'correct.'",
|
||||
"The Unnumbered is not many demons. It is the question of how many demons, left unanswered so long it grew teeth. Count it from one angle and there are three. From another, nine. The count is not a fact about the demons. It is a fact about you looking.",
|
||||
"The Angleworn Horror does not enter rooms. It enters corners, and corners are everywhere, and so it is always already present three rooms before you meet it. By the time it is 'here' it has been eating your flank since the last save point.",
|
||||
"The Echo of Belaxath drops nothing because it is not there. It is a recording the Unplace plays because the tear remembers who made it. Fighting it costs you and pays you nothing. I file this under 'toll,' which is the honest word for a thing you pay and do not buy.",
|
||||
"The Seamstress arrived to close the tear early, out of mercy, and the tear taught her the price of mercy applied to a wound this size: you do not close it from a distance, you close it with the only thread long enough, which is yourself. Half of her is on the far side now. I do not know what the far side does with the half it holds, and I have decided not to find out.",
|
||||
}
|
||||
|
||||
// SEAMSTRESS SIGNATURE CALLOUTS
|
||||
var SeamstressSignatureCallouts = []string{
|
||||
"Needle Rain. The ceiling fills with descending threads of light. I say: 'That's the pattern from the cabinet — move on the telegraph, not the impact.'",
|
||||
"She reels a thread taut across the arena; step over it or it cuts on the return. I call the line and you jump the line.",
|
||||
"The far half of her speaks and the near half's attacks land a half-second early. I recommend you dodge the voice, not the hand.",
|
||||
"She's threading toward your back seam. Turn into her — the Angleworn taught this room to hunt your flank, and she learned it too.",
|
||||
"Barbed sutures across the floor. They tighten. Whatever's inside the circle when they close, stays. I call it: 'Get out of the ring.'",
|
||||
"She pulls the room half a tile sideways — your hitbox and your sprite disagree for a beat. Trust where she's aiming, not where you're standing.",
|
||||
"A needle the length of a spear, one clean thrust down the lane you're standing in. I flag the lane. You leave the lane.",
|
||||
"She stitches a copy of the last attack into the room; it fires again on a delay you can count. I count it out loud so you don't have to.",
|
||||
"Threads converge on a single point and that point is your heart, plainly telegraphed. I say: 'Break line of sight or eat it — those are the two options.'",
|
||||
"She goes still and sews. Everything she mends on herself now, she means to unmend on you later. I file the pause under 'do damage, this is the window.'",
|
||||
}
|
||||
|
||||
// SEAMSTRESS PHASE TWO
|
||||
var SeamstressPhaseTwoLines = []string{
|
||||
"Below thirty-five percent she pulls the room inside-out and the far half comes forward. Now the threads run backward. I say: 'Watch the pulse — when it flares, what you'd call a wound on her is a gift, and what she calls a mercy on you draws blood.'",
|
||||
"Inversion Stitch. The room is sewn wrong-side-round and healing and harm swap seats on the telegraph. Mend her during the flare and it costs her; let her mend herself and it costs you. I recommend you learn to read the pulse faster than she can throw it.",
|
||||
"The near half has nothing left to say and the far half has stopped saying it to the tear. It's saying it to you now. Below thirty-five she is not closing the wound anymore — she is deciding you are the last thread she needs, and I recommend you disagree quickly.",
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -47,6 +48,13 @@ type Fact struct {
|
||||
Milestone string `json:"milestone,omitempty"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
||||
// Headline/Lede are LLM-authored prose for this fact, both optional. Pete
|
||||
// prefers them over its own template when present and past its prose-guard,
|
||||
// and falls back to the template otherwise — so an empty pair (LLM off, or
|
||||
// authoring failed) is the normal, safe case. Populated by emitFact; see
|
||||
// authorDispatch. Names in the prose must come only from Actors.
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Lede string `json:"lede,omitempty"`
|
||||
}
|
||||
|
||||
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
|
||||
@@ -62,12 +70,18 @@ type Config struct {
|
||||
// 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
|
||||
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
|
||||
@@ -118,11 +132,20 @@ func Emit(f Fact) {
|
||||
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
|
||||
return
|
||||
}
|
||||
// OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped.
|
||||
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, payload, created_at, attempts, next_attempt_at)
|
||||
VALUES (?, ?, unixepoch(), 0, 0)`,
|
||||
f.GUID, string(payload))
|
||||
`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
|
||||
@@ -147,10 +170,33 @@ func StartSender(ctx context.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
// 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, payload FROM pete_emit_queue
|
||||
`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)
|
||||
@@ -158,11 +204,11 @@ func (c *Client) drain(ctx context.Context) {
|
||||
slog.Error("peteclient: drain query", "err", err)
|
||||
return
|
||||
}
|
||||
type item struct{ guid, payload string }
|
||||
type item struct{ guid, path, payload string }
|
||||
var batch []item
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.guid, &it.payload); err != nil {
|
||||
if err := rows.Scan(&it.guid, &it.path, &it.payload); err != nil {
|
||||
slog.Error("peteclient: drain scan", "err", err)
|
||||
continue
|
||||
}
|
||||
@@ -174,7 +220,7 @@ func (c *Client) drain(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := c.send(ctx, []byte(it.payload)); err != nil {
|
||||
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
|
||||
@@ -206,14 +252,106 @@ type RosterEntry struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
IdleHours int `json:"idle_hours,omitempty"`
|
||||
|
||||
// Detail is the public, expanded sheet — stats and equipped gear — shown on
|
||||
// the click-through detail page. Nil for an entry we couldn't fully load.
|
||||
// Public-tier: no Matrix handle, keyed only by the anonymous Token, same as
|
||||
// the summary fields above.
|
||||
Detail *RosterDetail `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// RosterDetail is everything a visitor may see on an adventurer's detail page:
|
||||
// the current combat sheet and equipped gear, plus live expedition context. It
|
||||
// rides the roster push because it is small and shares the board's semantics —
|
||||
// a photograph of the present, fine to drop and refresh, never a handle.
|
||||
type RosterDetail struct {
|
||||
HPCurrent int `json:"hp_current"`
|
||||
HPMax int `json:"hp_max"`
|
||||
TempHP int `json:"temp_hp,omitempty"`
|
||||
ArmorClass int `json:"armor_class"`
|
||||
Abilities [6]int `json:"abilities"` // STR, DEX, CON, INT, WIS, CHA scores
|
||||
Modifiers [6]int `json:"modifiers"` // matching ability modifiers
|
||||
Gear []GearItem `json:"gear,omitempty"`
|
||||
// Expedition context, present only while on a run.
|
||||
Supplies int `json:"supplies,omitempty"`
|
||||
ThreatLevel int `json:"threat_level,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
Map *RosterMap `json:"map,omitempty"`
|
||||
}
|
||||
|
||||
// RosterMap is the fog-of-war cut of an adventurer's zone graph: every node
|
||||
// they have visited, plus the one-hop frontier of doors leading out of visited
|
||||
// nodes, with the rooms behind those doors withheld. It is per-adventurer and
|
||||
// rides the roster push beside Room. Only ids and kinds cross the wire — a
|
||||
// ZoneNode's Label and Content (encounter, loot bias, narration) are spoilers
|
||||
// and never leave the game box. Frontier nodes carry kind "unknown".
|
||||
type RosterMap struct {
|
||||
ZoneID string `json:"zone_id"`
|
||||
CurrentNode string `json:"current_node"`
|
||||
Visited []string `json:"visited"`
|
||||
Nodes []RosterMapNode `json:"nodes"`
|
||||
Edges []RosterMapEdge `json:"edges"`
|
||||
}
|
||||
|
||||
// RosterMapNode is one room reduced to what a public map may show.
|
||||
type RosterMapNode struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // ZoneNodeKind, or "unknown" for an unreached frontier room
|
||||
}
|
||||
|
||||
// RosterMapEdge is one directed passage. Lock names the gate kind
|
||||
// (perception_check, key_required, ...) so the map can mark a door as barred;
|
||||
// LockData and Hint stay behind on the game box.
|
||||
type RosterMapEdge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Lock string `json:"lock,omitempty"`
|
||||
}
|
||||
|
||||
// GearItem is one equipped piece for the armor/gear panel.
|
||||
type GearItem struct {
|
||||
Slot string `json:"slot"` // weapon | armor | helmet | boots | tool
|
||||
Name string `json:"name"`
|
||||
Tier int `json:"tier"`
|
||||
Condition int `json:"condition"`
|
||||
Masterwork bool `json:"masterwork,omitempty"`
|
||||
}
|
||||
|
||||
// MischiefBalance is one buyer's advisory euro balance, ridden along with the
|
||||
// board. It is keyed by localpart — a buyer's own sign-in name — not by the
|
||||
// anonymous roster token, so it lives in a separate keyspace on Pete and only
|
||||
// ever surfaces for the one authenticated user asking about themselves. That is
|
||||
// what lets the storefront grey out tiers a buyer can't afford without ever
|
||||
// putting a number next to a name on the public board.
|
||||
type MischiefBalance struct {
|
||||
Username string `json:"username"`
|
||||
Euro float64 `json:"euro"`
|
||||
}
|
||||
|
||||
// MischiefTier is one rung of the storefront price list. gogobee is the sole
|
||||
// authority on prices, so it pushes the whole catalog on every tick: a fee
|
||||
// retune reaches the storefront within a snapshot and Pete never hardcodes a
|
||||
// number that can silently drift out of step with the game.
|
||||
type MischiefTier struct {
|
||||
Key string `json:"key"`
|
||||
Display string `json:"display"`
|
||||
Fee int `json:"fee"`
|
||||
SignedFee int `json:"signed_fee"`
|
||||
Blurb string `json:"blurb,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.
|
||||
//
|
||||
// Balances and Tiers ride the same tick — advisory affordability and the live
|
||||
// price list for the mischief storefront. Both are best-effort on Pete's side; a
|
||||
// board that lands without them is still a good board.
|
||||
type RosterSnapshot struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Adventurers []RosterEntry `json:"adventurers"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Adventurers []RosterEntry `json:"adventurers"`
|
||||
Balances []MischiefBalance `json:"balances,omitempty"`
|
||||
Tiers []MischiefTier `json:"tiers,omitempty"`
|
||||
}
|
||||
|
||||
// PushRoster sends the board to Pete, synchronously, and drops it on failure.
|
||||
@@ -235,12 +373,162 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error {
|
||||
return std.post(ctx, "/api/ingest/roster", payload)
|
||||
}
|
||||
|
||||
// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the
|
||||
// bearer-POST pattern in email_nag.go:sendCode.
|
||||
func (c *Client) send(ctx context.Context, payload []byte) error {
|
||||
return c.post(ctx, "/api/ingest/adventure", payload)
|
||||
// PlayerDetail is the private, owner-only expansion for one player: inventory,
|
||||
// vault, house, and pets. Like MischiefBalance it is keyed by localpart (the
|
||||
// sign-in name), in its own keyspace on Pete — Pete only ever serves it back to
|
||||
// the one authenticated user it belongs to, never on the public board. It
|
||||
// carries the player's current board Token too, so Pete can answer "is the
|
||||
// signed-in viewer the owner of the adventurer on this page?" by a join, without
|
||||
// ever having to reverse the one-way token.
|
||||
type PlayerDetail struct {
|
||||
Localpart string `json:"localpart"`
|
||||
Token string `json:"token"`
|
||||
Inventory []ItemView `json:"inventory,omitempty"`
|
||||
Vault []ItemView `json:"vault,omitempty"`
|
||||
Equipped []ItemView `json:"equipped,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
// Slots is the 5 standard equipment slots (weapon/armor/helmet/boots/tool) for
|
||||
// the web management panel. Worn masterwork/arena pieces surface here (via
|
||||
// CanTakeOff), not in Equipped, which stays magic-only (the DnD slots).
|
||||
Slots []EquipSlotView `json:"slots,omitempty"`
|
||||
// Balance is the owner's euro balance, for the web's upgrade/repair confirm.
|
||||
// No omitempty: a €0 balance is a real, informative fact (a broke player), not
|
||||
// an absent one — dropping it would let the confirm dialog show a stale amount.
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
|
||||
// EquipSlotView is one of the 5 standard equipment slots, carrying what the web
|
||||
// management panel needs: what's worn now, whether it round-trips to the pack
|
||||
// (masterwork/arena), the next tier's name and price for an upgrade offer, and a
|
||||
// repair cost when the piece is damaged. Pete renders it and trusts only these
|
||||
// facts — a client-forged tier or price is resolved back against this view.
|
||||
type EquipSlotView struct {
|
||||
Slot string `json:"slot"` // weapon|armor|helmet|boots|tool
|
||||
Name string `json:"name"`
|
||||
Tier int `json:"tier"`
|
||||
Condition int `json:"condition"`
|
||||
Masterwork bool `json:"masterwork,omitempty"`
|
||||
ArenaTier int `json:"arena_tier,omitempty"`
|
||||
CanTakeOff bool `json:"can_take_off,omitempty"` // masterwork/arena → round-trippable
|
||||
NextTier int `json:"next_tier,omitempty"` // 0 = at max tier (5)
|
||||
NextName string `json:"next_name,omitempty"`
|
||||
NextPrice float64 `json:"next_price,omitempty"`
|
||||
RepairCost int `json:"repair_cost,omitempty"` // 0 = full condition
|
||||
}
|
||||
|
||||
// ItemView is one item in the private panels — backpack, vault, or worn.
|
||||
//
|
||||
// Slot/SkillSource/Desc/Effect are display resolutions done at the push site,
|
||||
// because an adventure_inventory row carries none of them: descriptions live on
|
||||
// MagicItem/EquipmentDef, and the combat delta is computed, never stored.
|
||||
//
|
||||
// SkillSource is only the player-facing skill a masterwork piece draws on
|
||||
// ("mining"). Inventory rows smuggle "magic_item:<id>" through the same column
|
||||
// as an internal registry pointer; that is not a fact about the item and never
|
||||
// goes on the wire.
|
||||
//
|
||||
// Attunement (does it need a bond) and Attuned (does it have one) are distinct:
|
||||
// with a hard cap of 3 bonds, a worn item can sit inert, and a player deciding
|
||||
// what to wear needs to see the difference.
|
||||
type ItemView struct {
|
||||
// ID is the adventure_inventory row id, sent only for a backpack item the
|
||||
// magic-item equip path will accept — so a non-zero ID is also the signal that
|
||||
// this item can be equipped from the web. Worn and vault rows carry none: a
|
||||
// worn item unequips by slot, and a vault item can't be equipped at all. Pete
|
||||
// round-trips this id in an equip order; the table is AUTOINCREMENT, so a stale
|
||||
// id (item already moved) misses cleanly rather than hitting the wrong row.
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
Slot string `json:"slot,omitempty"`
|
||||
SkillSource string `json:"skill_source,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Effect string `json:"effect,omitempty"`
|
||||
Attunement bool `json:"attunement,omitempty"`
|
||||
Attuned bool `json:"attuned,omitempty"`
|
||||
// Compare pairs a backpack magic item against whatever is worn in the slot it
|
||||
// would equip into, so the owner can tell an upgrade from a sidegrade without
|
||||
// eyeballing two opaque effect strings. Set only on backpack magic items (the
|
||||
// ones that carry an equip ID); worn and vault rows never have it. Owner-private,
|
||||
// rides detail_json — no migration, no public surface.
|
||||
Compare *ItemCompare `json:"compare,omitempty"`
|
||||
}
|
||||
|
||||
// ItemCompare is the per-stat verdict for equipping a backpack magic item over
|
||||
// what is currently worn in its slot. gogobee computes it (the power math folds
|
||||
// in tempering and bond availability, which live in the engine); Pete only
|
||||
// renders it and does no arithmetic.
|
||||
type ItemCompare struct {
|
||||
// Verdict is one of: upgrade, downgrade, sidegrade, same, new, inert.
|
||||
// upgrade every changed stat a gain
|
||||
// downgrade every changed stat a loss
|
||||
// sidegrade mixed — some better, some worse; no winner claimed
|
||||
// same no stat differs
|
||||
// new the target slot is empty; equipping fills it
|
||||
// inert needs a bond and none is free — wearing it would do nothing
|
||||
Verdict string `json:"verdict"`
|
||||
// VsName is the worn item being replaced; "" when Verdict is new (empty slot).
|
||||
VsName string `json:"vs_name,omitempty"`
|
||||
// VsSlot is the slot the item would land in (e.g. "ring_1").
|
||||
VsSlot string `json:"vs_slot,omitempty"`
|
||||
// Deltas is one entry per changed stat, each flagged better/worse. Engine-
|
||||
// rendered player-facing text; Pete draws arrows off Better and does no math.
|
||||
Deltas []ItemDelta `json:"deltas,omitempty"`
|
||||
}
|
||||
|
||||
// ItemDelta is one stat's change between the candidate item and the worn item.
|
||||
type ItemDelta struct {
|
||||
Label string `json:"label"`
|
||||
Better bool `json:"better"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// HouseView is the owner's housing summary.
|
||||
type HouseView struct {
|
||||
Tier int `json:"tier"`
|
||||
LoanBalance int `json:"loan_balance,omitempty"`
|
||||
Autopay bool `json:"autopay,omitempty"`
|
||||
Rate float64 `json:"rate,omitempty"`
|
||||
}
|
||||
|
||||
// PetView is one pet slot.
|
||||
type PetView struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
XP int `json:"xp,omitempty"`
|
||||
ArmorTier int `json:"armor_tier,omitempty"`
|
||||
}
|
||||
|
||||
// DetailSnapshot is the complete private-detail set, pushed whole and replacing
|
||||
// Pete's copy — same complete-snapshot contract as the roster, so a player who
|
||||
// drops out of the game stops having a stale self-view on Pete.
|
||||
type DetailSnapshot struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Players []PlayerDetail `json:"players"`
|
||||
}
|
||||
|
||||
// PushDetails sends the private self-detail set to Pete, best-effort. Like the
|
||||
// roster it is dropped on failure — the next tick carries a fresher copy — and
|
||||
// it rides its own endpoint (not the roster body) so a fat inventory can't blow
|
||||
// the roster push's size budget or its drop-the-lie semantics.
|
||||
func PushDetails(ctx context.Context, snap DetailSnapshot) error {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/ingest/detail", 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))
|
||||
@@ -261,6 +549,246 @@ func (c *Client) post(ctx context.Context, path string, payload []byte) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The mischief storefront's reverse pipe
|
||||
//
|
||||
// A buyer places a hit on Pete's web board; we poll for it, do the real work
|
||||
// against our own ledger, and hand back a verdict. Same shape as the escrow
|
||||
// border above — Pete has no route in, so we poll — but simpler: the order guid
|
||||
// is the end-to-end idempotency key (external_id on the euro debit, stamped on
|
||||
// the contract), and the *verdict rides the claim itself* rather than a durable
|
||||
// queue. If a claim fails, the order stays pending and the next poll re-offers
|
||||
// it; re-running is a no-op, so the poll loop is its own retry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// MischiefOrder is one storefront order as Pete describes it. buyer_sub stays on
|
||||
// Pete (it is the OIDC subject, only for "my orders"); we get the username, which
|
||||
// we turn into a Matrix id, and the anonymous target token.
|
||||
type MischiefOrder struct {
|
||||
GUID string `json:"guid"`
|
||||
BuyerUsername string `json:"buyer_username"`
|
||||
TargetToken string `json:"target_token"`
|
||||
TargetName string `json:"target_name"`
|
||||
Tier string `json:"tier"`
|
||||
Signed bool `json:"signed"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// PendingMischief asks Pete for orders waiting on us. A Pete that predates the
|
||||
// storefront answers 404, which surfaces here as an error; the poll loop treats
|
||||
// any error as "nothing to do this tick" and logs it quietly, so gogobee can ship
|
||||
// ahead of Pete without noise.
|
||||
func PendingMischief(ctx context.Context) ([]MischiefOrder, error) {
|
||||
if !Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
var out []MischiefOrder
|
||||
if err := std.getJSON(ctx, "/api/mischief/pending", &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClaimMischief files our verdict on an order: the terminal status Pete should
|
||||
// show and a human note. Idempotent on Pete, so a retried claim is safe. The
|
||||
// verdict rides this call directly — there is no separate durable emit, because
|
||||
// a lost claim just leaves the order pending for the next poll to re-run.
|
||||
func ClaimMischief(ctx context.Context, guid, status, detail string) error {
|
||||
payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/mischief/claim", payload)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The equip queue's reverse pipe
|
||||
//
|
||||
// An owner asks, on their own detail page, to wear or take off an item. Pete
|
||||
// records the intent; we poll for it, run the real equip against our own
|
||||
// equipment tables, and file a verdict. Same shape as mischief — Pete has no
|
||||
// route in — but with one crucial difference: the game action is NOT naturally
|
||||
// idempotent (equipping consumes an inventory row and regenerates it on
|
||||
// unequip), so re-running a drained order would double-move items. The poller
|
||||
// therefore short-circuits on the order guid before it mutates, the way
|
||||
// placeWebMischief does on its contract; the guid is still the end-to-end key,
|
||||
// but here it guards a non-idempotent action rather than riding a naturally
|
||||
// idempotent one.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EquipOrder is one equip/unequip as Pete describes it. owner_localpart is the
|
||||
// Matrix localpart of the character to dress; item_id is the adventure_inventory
|
||||
// row id for an equip (0 for an unequip, which keys on slot). character_name and
|
||||
// item_name are display copy Pete froze at order time; we don't need them.
|
||||
type EquipOrder struct {
|
||||
GUID string `json:"guid"`
|
||||
OwnerLocalpart string `json:"owner_localpart"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
ItemName string `json:"item_name"`
|
||||
Slot string `json:"slot"`
|
||||
Action string `json:"action"` // equip / unequip / upgrade / repair
|
||||
Tier int `json:"tier"` // upgrade target tier (an EquipmentSlot tier); unused by the others
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// PendingEquip asks Pete for equip orders waiting on us. A Pete predating the
|
||||
// queue answers 404, surfaced here as an error the poll loop logs quietly.
|
||||
func PendingEquip(ctx context.Context) ([]EquipOrder, error) {
|
||||
if !Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
var out []EquipOrder
|
||||
if err := std.getJSON(ctx, "/api/equip/pending", &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// VerdictEquip files our verdict on an equip order. Idempotent on Pete, so a
|
||||
// retried verdict is safe; the verdict rides this call directly.
|
||||
func VerdictEquip(ctx context.Context, guid, status, detail string) error {
|
||||
payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/equip/verdict", 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 {
|
||||
|
||||
@@ -1205,6 +1205,11 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
Emoji: "🗺️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_clear_t6", Name: "Off the Edge of the Map", Description: "Cleared a Mythic zone. The map never went this far. Neither should you have.",
|
||||
Emoji: "🗺️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 6) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_master_t1", Name: "Warren Cartography", Description: "Cleared every Tier 1 zone. Thorough.",
|
||||
Emoji: "🧭",
|
||||
@@ -1230,6 +1235,11 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
Emoji: "🧭",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_master_t6", Name: "There Was No Sixth Tier", Description: "Cleared every Mythic zone. Officially, none of these existed.",
|
||||
Emoji: "🧭",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 6) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_quiet_clear", Name: "Nobody Saw Anything", Description: "Cleared a zone without threat ever passing 50. You were never here.",
|
||||
Emoji: "🌑",
|
||||
|
||||
89
internal/plugin/admin_char_migrate.go
Normal file
89
internal/plugin/admin_char_migrate.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// AdminBuildConfirmedCharacter mints (or overwrites) a confirmed D&D character
|
||||
// for one user with an explicitly chosen race/class/level, reusing the same
|
||||
// constructors as auto-migration and !setup confirm: class-tuned standard
|
||||
// array + racial mods, the canonical HP/AC formulas, resource pool, and — for
|
||||
// casters — the starter spell list and slot pool.
|
||||
//
|
||||
// It exists for the "stuck adventurer" cases the boredom ticker can't reach on
|
||||
// its own:
|
||||
//
|
||||
// - A veteran legacy player with an adventure_characters row but no
|
||||
// dnd_character (never triggered auto-migration because auto-migration only
|
||||
// fires on active play — an idle player never does). Pass the class/race the
|
||||
// faithful migration would infer to reproduce it exactly.
|
||||
// - A player who abandoned !setup at race pick (pending_setup=1, no class), so
|
||||
// tryBoredomStart skips them forever. This overwrites the draft with a
|
||||
// finished sheet.
|
||||
//
|
||||
// AutoMigrated is set so the player can freely rebuild via !setup (no respec
|
||||
// cooldown) when they return — the class was assigned for them, not chosen.
|
||||
//
|
||||
// SaveDnDCharacter upserts on user_id, so an existing pending draft is replaced.
|
||||
// initResources / ensureSpellsForCharacter are idempotent.
|
||||
func AdminBuildConfirmedCharacter(userID id.UserID, race DnDRace, class DnDClass, level int) (*DnDCharacter, error) {
|
||||
// Validate (and normalize) race/class through the same parsers !setup uses,
|
||||
// so a typo or a non-playable class fails loudly instead of silently minting
|
||||
// a broken sheet — an unknown class falls through classInfo to 1 HP / AC 10
|
||||
// / no spells, which looks "built" but isn't.
|
||||
r, ok := parseRace(string(race))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown race %q", race)
|
||||
}
|
||||
cl, ok := parseClass(string(class))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown or non-playable class %q", class)
|
||||
}
|
||||
race, class = r, cl
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
if level > dndMaxLevel {
|
||||
level = dndMaxLevel
|
||||
}
|
||||
scores := applyRaceMods(race, classStatPriority(class))
|
||||
c := &DnDCharacter{
|
||||
UserID: userID,
|
||||
Race: race,
|
||||
Class: class,
|
||||
Level: level,
|
||||
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||||
PendingSetup: false,
|
||||
AutoMigrated: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
conMod := abilityModifier(c.CON)
|
||||
dexMod := abilityModifier(c.DEX)
|
||||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||||
c.ShortRestCharges = c.Level
|
||||
|
||||
if err := ensurePlayerMetaSeed(userID); err != nil {
|
||||
return nil, fmt.Errorf("seed player_meta: %w", err)
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return nil, fmt.Errorf("save dnd_character: %w", err)
|
||||
}
|
||||
if err := initResources(userID, c.Class); err != nil {
|
||||
return nil, fmt.Errorf("init resources: %w", err)
|
||||
}
|
||||
if err := ensureSpellsForCharacter(c); err != nil {
|
||||
return nil, fmt.Errorf("seed spells: %w", err)
|
||||
}
|
||||
slog.Info("admin: built confirmed character",
|
||||
"user", userID, "race", race, "class", class, "level", level,
|
||||
"hp", c.HPMax, "ac", c.ArmorClass)
|
||||
return c, nil
|
||||
}
|
||||
@@ -290,6 +290,8 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.expeditionExtractionSweepTicker()
|
||||
go p.expeditionBoredomTicker()
|
||||
go p.mischiefTicker()
|
||||
go p.peteMischiefTicker()
|
||||
go p.peteEquipTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
@@ -532,6 +534,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.handleEquipCmd(ctx)
|
||||
case lower == "equip-magic" || lower == "equipmagic" || lower == "equip magic":
|
||||
return p.handleEquipMagicCmd(ctx)
|
||||
case lower == "unequip-magic" || lower == "unequipmagic" || lower == "unequip magic":
|
||||
return p.handleUnequipMagicCmd(ctx)
|
||||
case lower == "inventory" || lower == "inv":
|
||||
return p.handleInventoryCmd(ctx)
|
||||
case lower == "leaderboard" || lower == "lb":
|
||||
@@ -593,6 +597,7 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
|
||||
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
|
||||
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
|
||||
` + "`!adventure unequip-magic`" + ` — Take a worn magic item off and return it to inventory
|
||||
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
|
||||
` + "`!adventure inventory`" + ` — View your inventory
|
||||
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
|
||||
@@ -939,6 +944,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
return p.handleMasterworkEquipReply(ctx, interaction)
|
||||
case "magic_equip":
|
||||
return p.resolveMagicEquipReply(ctx, interaction)
|
||||
case "magic_unequip":
|
||||
return p.resolveMagicUnequipReply(ctx, interaction)
|
||||
case "masterwork_equip_confirm":
|
||||
return p.handleMasterworkEquipConfirm(ctx, interaction)
|
||||
case "rival_rps":
|
||||
@@ -1376,6 +1383,10 @@ func (p *AdventurePlugin) announceTreasureToRoom(char *AdventureCharacter, def *
|
||||
if def == nil || def.RoomAnnounce == "" {
|
||||
return
|
||||
}
|
||||
// The same story-grade gate feeds Pete's trophy case. Emit before the
|
||||
// games-room check so a find is still recorded as news even when no room is
|
||||
// configured to announce it in.
|
||||
emitTreasureFound(char.UserID, def, loc)
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
|
||||
@@ -231,6 +231,30 @@ var secretRoomDiscovery = map[string]string{
|
||||
"feywild_crossing.illusion_garden": "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
|
||||
"dragons_lair.hoard_pillar": "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
|
||||
"abyss_portal.reality_seam": "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",
|
||||
|
||||
// Tier 6 post-game — the Phylactery Verses of the Ossuary Ascendant. Each
|
||||
// is a stanza of the plan Valdris has been reciting to himself for years;
|
||||
// finding one un-writes a line of his invulnerability before the fight.
|
||||
"ossuary_ascendant.f1b2": "🔍 **The Verse of the Waiting Grave.** Behind a course of bone laid one brick too shallow, the first stanza — inked while he was still only pretending to be dead. Read aloud, it costs him something he was counting on keeping.",
|
||||
"ossuary_ascendant.f2b2": "🔍 **The Verse of the Bait Long Set.** A reliquary that rings hollow gives up the middle stanza: the years of losing on purpose, written as scripture. He signed each defeat like an appointment kept.",
|
||||
"ossuary_ascendant.cap32": "🔍 **The Verse of the Homecoming.** The highest vertebra was set wrong on purpose, and the last stanza waits inside it — the line about you, by name, arriving exactly on schedule. He did not expect you to read it first.",
|
||||
|
||||
// The Unplace — impossible-geometry pockets. Not hidden so much as
|
||||
// briefly, wrongly, adjacent.
|
||||
"unplace.f1b2": "🔍 **The Pocket That Isn't.** A corner you already turned is somehow still ahead, and the room it opens onto was never built — but something has been storing things in it anyway.",
|
||||
"unplace.f2b2": "🔍 **The Unstitched Pocket.** Between two seams the celestial hasn't reached, a gap the geometry forgot to close. What fell through the tear years ago settled here, waiting to be un-fallen.",
|
||||
"unplace.cap32": "🔍 **The Undone Pocket.** A place that is not here, and closer than the door — the shortcut that shouldn't be legal. Whoever last took it left their toll behind, undone but not gone.",
|
||||
|
||||
// The Drowned Star — radiant pockets of the wreck, lit from within by the
|
||||
// dying star Seraphel still shields.
|
||||
"drowned_star.f1b2": "🔍 **The Radiant Wreck-Pocket.** Off the pilgrim road, past a pressure that folds lesser divers, a pocket of hull still glowing with the star's oldest light — and the offerings of pilgrims who made it this far and no further.",
|
||||
"drowned_star.f2b2": "🔍 **The Star-Lit Vault.** One lantern here is not a lure. It leads to a vault the trench sealed with radiance instead of stone, and the light has been keeping its contents ten thousand years fresh.",
|
||||
"drowned_star.cap32": "🔍 **The Heart's Own Light.** A crack in the radiance, where the star leaks the light it was born with. Reaching in is reaching into the thing Seraphel has died a little every day to protect.",
|
||||
|
||||
// The Last Meridian — hours the Custodian hasn't decommissioned yet.
|
||||
"last_meridian.f1b2": "🔍 **The Unspent Hour-Pocket.** The Hour Thief overlooked one recess, and the hour it holds is still whole — sixty unspent minutes the observatory never got to bill.",
|
||||
"last_meridian.f2b2": "🔍 **The Unwound Vault.** Behind the escapement, on the rhythm's dead beat, a strongroom the pendulum's swing kept sealed. Time out of phase with the rest of the clock, and everything left in it, out of phase too.",
|
||||
"last_meridian.cap32": "🔍 **The Hour Never Struck.** One hour on the great face was never dismantled — never even struck. It is still open, and inside it the world's timekeeping kept a little of everything it measured.",
|
||||
}
|
||||
|
||||
// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
|
||||
@@ -261,6 +285,13 @@ var bossEpilogues = map[ZoneID]string{
|
||||
ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.",
|
||||
ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.",
|
||||
ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.",
|
||||
|
||||
// Tier 6 post-game epilogues — beyond the edge of the map.
|
||||
ZoneOssuaryAscendant: "Valdris does not gloat when he goes; he files. His last breath is a clerk's satisfaction — the plan closed, the ledger squared, the grave at last furnished to code. He dies the only person here who was never surprised by anything, and that, more than the crown, is what you take home.",
|
||||
ZoneFirstHoard: "Aurvandryx never wanted the gold; the gold wanted her, gathered itself into a bed around her clutch the way frost gathers on the warmest thing left in a room. She dies shielding eggs that will not hatch, and the mountain finally, truly cools. Every dragon you ever fought was a scale she shed and forgot.",
|
||||
ZoneUnplace: "The Seamstress finishes her stitch as she falls, and the wound she was sewing shut simply — stops needing to be. The Unplace folds back into a single honest room. She volunteered for this centuries early, and no one ever came to relieve her. You are the closest thing to relief she got.",
|
||||
ZoneDrownedStar: "Seraphel lets go. Ten thousand years she would not, and now the dying star sinks the last fathom into the dark and is, mercifully, only dark. She is not sad. She is off-duty, for the first time since the world had names, and the trench goes quiet in a way that is almost a held breath finally let out.",
|
||||
ZoneLastMeridian: "The Custodian apologizes one final time, on schedule, and then the hour it was dismantling simply doesn't arrive. The clocks do not stop so much as agree to stop mattering. Its contract is complete. It thanks you for your patience — and you realize, too late to ask, who it was that hired it.",
|
||||
}
|
||||
|
||||
// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for
|
||||
|
||||
@@ -74,6 +74,27 @@ const (
|
||||
|
||||
// Fizzle: the expedition ended before the monster could find them.
|
||||
mischiefFizzleRefund = 0.90
|
||||
|
||||
// A ward is priced against the contract it is defending: a floor, or a slice of
|
||||
// the fee, whichever is more. Warding someone has to stay an impulse at the
|
||||
// cheap tiers (or nobody bothers and the window is just a countdown), but the
|
||||
// M1-close-out sweep measured three wards buying roughly +40pp of survival —
|
||||
// fighter L12 vs a boss at 70% HP goes 48% → 90%. At a flat fee that let the
|
||||
// room halve a €1,200 boss contract for €75, which makes the tier the whole
|
||||
// economy rests on feel like money thrown away. Saving someone from a boss
|
||||
// should cost the town real money: €120 a ward, €360 to cover them completely.
|
||||
mischiefBlessingFeeMin = 25
|
||||
mischiefBlessingFeePct = 0.10
|
||||
|
||||
// mischiefBlessingCap — how many blessings one contract can carry. Three at
|
||||
// +10% MaxHP each is a third of a health bar: enough to swing an elite
|
||||
// coin-flip, not enough to make a boss survivable on its own.
|
||||
mischiefBlessingCap = 3
|
||||
|
||||
// mischiefBlessingPct — temp HP per blessing, as a fraction of the target's
|
||||
// MaxHP. Same cushion mechanism as a well-rested long rest (applyDnDHPScaling),
|
||||
// and like it, evaporates when the fight's HP is persisted back.
|
||||
mischiefBlessingPct = 0.10
|
||||
)
|
||||
|
||||
// Contract statuses. `delivering` is the claimed-but-unresolved state a CAS
|
||||
@@ -123,6 +144,102 @@ func mischiefTierByKey(key string) (mischiefTier, bool) {
|
||||
return mischiefTier{}, false
|
||||
}
|
||||
|
||||
// mischiefNextTier is the rung an escalation buys. Boss is the top: there is
|
||||
// nothing worse in the bracket to send.
|
||||
func mischiefNextTier(key string) (mischiefTier, bool) {
|
||||
for i, t := range mischiefTiers {
|
||||
if t.Key == key && i+1 < len(mischiefTiers) {
|
||||
return mischiefTiers[i+1], true
|
||||
}
|
||||
}
|
||||
return mischiefTier{}, false
|
||||
}
|
||||
|
||||
// mischiefPrevTier is the rung an escalated contract came FROM. Grunt is the
|
||||
// bottom: nothing escalates into it.
|
||||
func mischiefPrevTier(key string) (mischiefTier, bool) {
|
||||
for i, t := range mischiefTiers {
|
||||
if t.Key == key && i > 0 {
|
||||
return mischiefTiers[i-1], true
|
||||
}
|
||||
}
|
||||
return mischiefTier{}, false
|
||||
}
|
||||
|
||||
// mischiefOutlay splits what a contract collected into the buyer's stake and the
|
||||
// escalator's. It exists because `paid` carries BOTH — escalateMischiefContract
|
||||
// adds the delta to it so the purse cap keeps biting — and every path that gives
|
||||
// money back has to give each of them back their own. Crediting the whole of
|
||||
// `paid` to the buyer turns being escalated on top of into a windfall: buy an
|
||||
// elite for €350, have someone bump it to a boss (+€850), let the target extract,
|
||||
// and the 90% fizzle refund hands the buyer €1080 of somebody else's money.
|
||||
//
|
||||
// The escalator's stake is derived, not stored. An escalation is exactly one rung
|
||||
// and happens at most once (escalation_count = 0 is in the UPDATE's WHERE), so it
|
||||
// is always the fee gap between the contract's tier and the one below it.
|
||||
func mischiefOutlay(c *mischiefContract) (buyer, escalator int) {
|
||||
if c.EscalatedBy == "" || c.EscalationCount == 0 {
|
||||
return c.Paid, 0
|
||||
}
|
||||
cur, okCur := mischiefTierByKey(c.Tier)
|
||||
prev, okPrev := mischiefPrevTier(c.Tier)
|
||||
if !okCur || !okPrev {
|
||||
return c.Paid, 0
|
||||
}
|
||||
delta := cur.Fee - prev.Fee
|
||||
if delta <= 0 || delta > c.Paid {
|
||||
return c.Paid, 0 // nonsense ladder; don't invent money out of it
|
||||
}
|
||||
return c.Paid - delta, delta
|
||||
}
|
||||
|
||||
// trackMischiefSink books a sink against the people whose money it actually was.
|
||||
// The escalator's stake sits inside c.Paid, so charging the whole rake to the
|
||||
// buyer would credit them with tax they never paid — and credit the escalator,
|
||||
// who funded most of a boss contract, with none.
|
||||
func trackMischiefSink(c *mischiefContract, amount int) {
|
||||
buyerPaid, escPaid := mischiefOutlay(c)
|
||||
total := buyerPaid + escPaid
|
||||
if amount <= 0 || total <= 0 {
|
||||
return
|
||||
}
|
||||
escShare := amount * escPaid / total
|
||||
if escShare > 0 {
|
||||
trackTaxPaid(c.EscalatedBy, escShare)
|
||||
}
|
||||
if buyerShare := amount - escShare; buyerShare > 0 {
|
||||
trackTaxPaid(c.BuyerID, buyerShare)
|
||||
}
|
||||
}
|
||||
|
||||
// mischiefBlessingFee is what one ward costs against a contract of this fee. It
|
||||
// reads the contract's CURRENT basis, so an escalation raises the price of
|
||||
// protecting its target too — the room's counterplay tracks the threat.
|
||||
func mischiefBlessingFee(contractFee int) int {
|
||||
fee := int(math.Round(float64(contractFee) * mischiefBlessingFeePct))
|
||||
if fee < mischiefBlessingFeeMin {
|
||||
return mischiefBlessingFeeMin
|
||||
}
|
||||
return fee
|
||||
}
|
||||
|
||||
// mischiefBlessingTempHP is the cushion a contract's blessings are worth to a
|
||||
// target of this MaxHP. At least 1 per blessing, so a low-HP character still
|
||||
// feels the ward they were bought.
|
||||
func mischiefBlessingTempHP(hpMax, blessings int) int {
|
||||
if blessings <= 0 || hpMax <= 0 {
|
||||
return 0
|
||||
}
|
||||
if blessings > mischiefBlessingCap {
|
||||
blessings = mischiefBlessingCap
|
||||
}
|
||||
per := int(float64(hpMax) * mischiefBlessingPct)
|
||||
if per < 1 {
|
||||
per = 1
|
||||
}
|
||||
return per * blessings
|
||||
}
|
||||
|
||||
// mischiefSignedFee is what a buyer pays to put their name on the contract up
|
||||
// front. The extra is a sink — mischiefPurse never sees it.
|
||||
func mischiefSignedFee(fee int) int {
|
||||
@@ -229,21 +346,24 @@ type mischiefContract struct {
|
||||
Status string
|
||||
Signed bool
|
||||
EscalationCount int
|
||||
EscalatedBy id.UserID // "" unless somebody paid to bump the tier
|
||||
BlessingCount int
|
||||
Source string
|
||||
Outcome string
|
||||
OrderGUID string // the web order this was opened for; "" for a Matrix buy
|
||||
CreatedAt time.Time
|
||||
WindowEndsAt time.Time
|
||||
}
|
||||
|
||||
const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed,
|
||||
escalation_count, blessing_count, source, COALESCE(outcome, ''), created_at, window_ends_at`
|
||||
escalation_count, COALESCE(escalated_by, ''), blessing_count, source, COALESCE(outcome, ''),
|
||||
COALESCE(order_guid, ''), created_at, window_ends_at`
|
||||
|
||||
func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error) {
|
||||
c := &mischiefContract{}
|
||||
err := row.Scan(&c.ID, &c.BuyerID, &c.TargetID, &c.Tier, &c.Fee, &c.Paid, &c.Status,
|
||||
&c.Signed, &c.EscalationCount, &c.BlessingCount, &c.Source, &c.Outcome,
|
||||
&c.CreatedAt, &c.WindowEndsAt)
|
||||
&c.Signed, &c.EscalationCount, &c.EscalatedBy, &c.BlessingCount, &c.Source, &c.Outcome,
|
||||
&c.OrderGUID, &c.CreatedAt, &c.WindowEndsAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -263,14 +383,70 @@ func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error
|
||||
func insertMischiefContract(c *mischiefContract) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO mischief_contracts
|
||||
(contract_id, buyer_id, target_id, tier, fee, paid, status, signed, source,
|
||||
(contract_id, buyer_id, target_id, tier, fee, paid, status, signed, source, order_guid,
|
||||
created_at, window_ends_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, string(c.BuyerID), string(c.TargetID), c.Tier, c.Fee, c.Paid,
|
||||
c.Status, c.Signed, c.Source, c.CreatedAt, c.WindowEndsAt)
|
||||
c.Status, c.Signed, c.Source, c.OrderGUID, c.CreatedAt, c.WindowEndsAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// mischiefContractByOrderGUID finds the contract opened for a web order, if one
|
||||
// exists. It is the idempotency backstop for the storefront's retrying claim
|
||||
// loop: a placement that already created a contract must not create a second, so
|
||||
// the loop checks here before it debits or inserts. Returns nil when no contract
|
||||
// carries this order (the normal first-attempt case).
|
||||
func mischiefContractByOrderGUID(orderGUID string) *mischiefContract {
|
||||
if orderGUID == "" {
|
||||
return nil
|
||||
}
|
||||
row := db.Get().QueryRow(
|
||||
`SELECT `+mischiefCols+` FROM mischief_contracts WHERE order_guid = ?`, orderGUID)
|
||||
c, err := scanMischief(row)
|
||||
if err != nil {
|
||||
return nil // sql.ErrNoRows (the common case) or a scan miss — treat as absent
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// blessMischiefContract adds one ward to an open contract and reports whether it
|
||||
// took. The cap and the still-open check are in the WHERE clause, not in the
|
||||
// caller: the window is a scramble, and a room that piles four blessings in the
|
||||
// same second must still land exactly three. A caller that loses this race has
|
||||
// already been debited and must refund.
|
||||
func blessMischiefContract(contractID string) bool {
|
||||
res := db.ExecResult("mischief: bless contract",
|
||||
`UPDATE mischief_contracts SET blessing_count = blessing_count + 1
|
||||
WHERE contract_id = ? AND status = ? AND blessing_count < ?`,
|
||||
contractID, mischiefStatusOpen, mischiefBlessingCap)
|
||||
if res == nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// escalateMischiefContract bumps an open contract one rung: the tier, the payout
|
||||
// basis (fee) and the buyer-side outlay (paid) all move together, so the purse
|
||||
// stays a percentage of what was actually spent and the 75% cap survives an
|
||||
// escalation untouched.
|
||||
//
|
||||
// The escalation_count = 0 and tier = <from> guards are what make it one step,
|
||||
// once, even if two people hit `!mischief escalate` on the same contract at the
|
||||
// same moment. The loser is refunded.
|
||||
func escalateMischiefContract(contractID, fromTier string, next mischiefTier, delta int, by id.UserID) bool {
|
||||
res := db.ExecResult("mischief: escalate contract",
|
||||
`UPDATE mischief_contracts
|
||||
SET tier = ?, fee = ?, paid = paid + ?, escalation_count = 1, escalated_by = ?
|
||||
WHERE contract_id = ? AND status = ? AND tier = ? AND escalation_count = 0`,
|
||||
next.Key, next.Fee, delta, string(by), contractID, mischiefStatusOpen, fromTier)
|
||||
if res == nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// claimMischiefForDelivery moves open → delivering and reports whether THIS
|
||||
// caller won the race. Exactly one delivery per contract, whatever the ticker
|
||||
// does across a restart.
|
||||
@@ -480,9 +656,14 @@ func (p *AdventurePlugin) handleMischiefCmd(ctx MessageContext, args string) err
|
||||
return p.mischiefStatusCmd(ctx)
|
||||
case "send":
|
||||
return p.mischiefSendCmd(ctx, fields[1:])
|
||||
case "bless":
|
||||
return p.mischiefBlessCmd(ctx, fields[1:])
|
||||
case "escalate":
|
||||
return p.mischiefEscalateCmd(ctx, fields[1:])
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Unknown option. `!mischief` for the board · `!mischief send <tier> @user` · `!mischief status`")
|
||||
"Unknown option. `!mischief` for the board · `!mischief send <tier> @user` · "+
|
||||
"`!mischief bless @user` · `!mischief escalate @user` · `!mischief status`")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,11 +673,18 @@ func (p *AdventurePlugin) mischiefBoard() string {
|
||||
b.WriteString("The monster comes from their own bracket, so it's always a fight worth watching. ")
|
||||
b.WriteString("If they survive it, they keep a cut of your money — and the town finds out it was you.\n\n")
|
||||
for _, t := range mischiefTiers {
|
||||
b.WriteString(fmt.Sprintf("· **%s** — %s (signed: %s) — survivor keeps %s\n _%s_\n",
|
||||
b.WriteString(fmt.Sprintf("· **%s** — %s (signed: %s) — survivor keeps %s · ward %s\n _%s_\n",
|
||||
t.Display, fmtEuro(t.Fee), fmtEuro(mischiefSignedFee(t.Fee)),
|
||||
fmtEuro(mischiefPurse(t.Fee, t.PayoutPct)), t.Blurb))
|
||||
fmtEuro(mischiefPurse(t.Fee, t.PayoutPct)), fmtEuro(mischiefBlessingFee(t.Fee)), t.Blurb))
|
||||
}
|
||||
b.WriteString("\n`!mischief send <tier> @user` — anonymous · add `signed` to put your name on it up front.\n")
|
||||
b.WriteString(fmt.Sprintf(
|
||||
"While a contract is open, the rest of us get a say:\n"+
|
||||
"· `!mischief bless @user` — a ward for the fight, up to %d of them. Help them. "+
|
||||
"The worse the thing coming, the more a ward costs.\n"+
|
||||
"· `!mischief escalate @user` — pay the difference, send something worse. \"Help\" them. "+
|
||||
"It raises their purse too, if they live.\n",
|
||||
mischiefBlessingCap))
|
||||
b.WriteString(fmt.Sprintf("_They have to be out on an expedition, level %d+. It lands about %s later._",
|
||||
mischiefMinLevel, formatDuration(mischiefWindow)))
|
||||
return b.String()
|
||||
@@ -509,10 +697,14 @@ func (p *AdventurePlugin) mischiefStatusCmd(ctx MessageContext) error {
|
||||
if c.Signed {
|
||||
who = "**" + p.DisplayName(c.BuyerID) + "**"
|
||||
}
|
||||
wards := ""
|
||||
if c.BlessingCount > 0 {
|
||||
wards = fmt.Sprintf("\n🕯️ %d of the town's wards are on you.", c.BlessingCount)
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"😈 %s has paid for a **%s** to find you. It arrives %s. Survive it and you keep %s.",
|
||||
"😈 %s has paid for a **%s** to find you. It arrives %s. Survive it and you keep %s.%s",
|
||||
who, strings.ToLower(t.Display), formatDuration(time.Until(c.WindowEndsAt)),
|
||||
fmtEuro(mischiefPurse(c.Fee, t.PayoutPct))))
|
||||
fmtEuro(mischiefPurse(c.Fee, t.PayoutPct)), wards))
|
||||
}
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT `+mischiefCols+` FROM mischief_contracts
|
||||
@@ -617,6 +809,7 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e
|
||||
}
|
||||
|
||||
p.announceMischiefContract(c, tier)
|
||||
p.dmMischiefVictim(c, tier)
|
||||
emitMischiefContractNews(c, tier)
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
@@ -625,6 +818,333 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e
|
||||
mischiefBuyerNote(signed)))
|
||||
}
|
||||
|
||||
// Web-order verdicts. These strings are the storefront order statuses Pete files,
|
||||
// so they are part of the wire contract — see internal/storage/mischief.go.
|
||||
const (
|
||||
mischiefWebPlaced = "placed"
|
||||
mischiefWebBouncedFunds = "bounced_funds"
|
||||
mischiefWebBouncedIneligible = "bounced_ineligible"
|
||||
)
|
||||
|
||||
// mischiefWebResult is the outcome of a web placement. Retry means "leave the
|
||||
// order pending" — a transient failure (DB hiccup, half-done state) the poll loop
|
||||
// should try again — and is never itself a verdict Pete files.
|
||||
type mischiefWebResult struct {
|
||||
Status string
|
||||
Detail string
|
||||
Retry bool
|
||||
}
|
||||
|
||||
// placeWebMischief opens a contract for a web storefront order. It is the poll
|
||||
// loop's fulfilment path, and differs from the Matrix mischiefSendCmd in exactly
|
||||
// the ways a retrying wire demands:
|
||||
//
|
||||
// - The order guid is the idempotency key. The euro debit goes through
|
||||
// DebitIdem keyed on it, and the contract is stamped with it, so a claim
|
||||
// whose ack was lost can re-run the whole thing without charging twice or
|
||||
// opening a second contract.
|
||||
// - It debits *before* checking eligibility, then refunds if the target turns
|
||||
// out ineligible. Doing it in that order keeps the money state a pure
|
||||
// function of the guid: on any retry the debit is a settled fact, not a
|
||||
// coin-flip on where we crashed. The Matrix path can afford the friendlier
|
||||
// check-first flow because it never retries.
|
||||
// - It returns a verdict for Pete to render instead of replying in a room.
|
||||
//
|
||||
// The buyer is DM'd the same way a Matrix buyer is told, and the victim and news
|
||||
// feed see exactly what a Matrix contract produces — a web hit is indistinguishable
|
||||
// downstream from one placed in chat.
|
||||
func (p *AdventurePlugin) placeWebMischief(order peteclient.MischiefOrder) mischiefWebResult {
|
||||
// Already fulfilled on a prior attempt — the surest idempotency check there
|
||||
// is. Report placed without touching money again.
|
||||
if existing := mischiefContractByOrderGUID(order.GUID); existing != nil {
|
||||
return mischiefWebResult{Status: mischiefWebPlaced, Detail: "already placed"}
|
||||
}
|
||||
|
||||
tier, ok := mischiefTierByKey(order.Tier)
|
||||
if !ok {
|
||||
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "unknown tier"}
|
||||
}
|
||||
|
||||
buyerID, ok := p.mischiefBuyerMXID(order.BuyerUsername)
|
||||
if !ok {
|
||||
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "unknown buyer"}
|
||||
}
|
||||
targetID, ok := resolveRosterToken(order.TargetToken)
|
||||
if !ok {
|
||||
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "that adventurer is no longer on the board"}
|
||||
}
|
||||
if targetID == buyerID {
|
||||
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "you can't put a price on your own head"}
|
||||
}
|
||||
|
||||
// Serialise this buyer's placements, same discipline as the Matrix path: two
|
||||
// orders in one poll batch can't both slip past the daily cap.
|
||||
lock := p.advUserLock(buyerID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
if n := mischiefBuyerCountSince(buyerID, now.Add(-24*time.Hour)); n >= mischiefBuyerDailyCap {
|
||||
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: fmt.Sprintf("daily limit reached (%d today)", n)}
|
||||
}
|
||||
|
||||
price := tier.Fee
|
||||
if order.Signed {
|
||||
price = mischiefSignedFee(tier.Fee)
|
||||
}
|
||||
|
||||
// Affordability gate, matching the Matrix path: a mischief buy takes no credit,
|
||||
// so a buyer short of the fee is bounced before any money moves. The gate is
|
||||
// skipped on a retry of an order already debited — re-reading the now-lower
|
||||
// balance would wrongly bounce a hit that was already paid for.
|
||||
if !p.euro.HasExternalTx(order.GUID) && int(p.euro.GetBalance(buyerID)) < price {
|
||||
return mischiefWebResult{Status: mischiefWebBouncedFunds, Detail: fmt.Sprintf("a %s runs %s", strings.ToLower(tier.Display), fmtEuro(price))}
|
||||
}
|
||||
|
||||
// Debit, keyed on the order guid so a replay is a no-op. ok=false means the
|
||||
// buyer is already past the debt floor, which the gate above all but rules out.
|
||||
debited, _, err := p.euro.DebitIdem(buyerID, float64(price), "mischief_contract_web", order.GUID)
|
||||
if err != nil {
|
||||
slog.Warn("mischief: web debit errored, will retry", "order", order.GUID, "err", err)
|
||||
return mischiefWebResult{Retry: true}
|
||||
}
|
||||
if !debited {
|
||||
return mischiefWebResult{Status: mischiefWebBouncedFunds, Detail: fmt.Sprintf("a %s runs %s", strings.ToLower(tier.Display), fmtEuro(price))}
|
||||
}
|
||||
|
||||
// From here the buyer is debited; every failure path must refund. The refund
|
||||
// keys on a distinct external id so it can't be swallowed as a replay of the
|
||||
// debit itself.
|
||||
refund := func() {
|
||||
if _, _, err := p.euro.CreditIdem(buyerID, float64(price), "mischief_refund_web", order.GUID+":refund"); err != nil {
|
||||
slog.Error("mischief: web refund failed — buyer is short", "order", order.GUID, "buyer", buyerID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if reason := p.mischiefTargetable(targetID, tier, now); reason != "" {
|
||||
refund()
|
||||
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: reason}
|
||||
}
|
||||
|
||||
c := &mischiefContract{
|
||||
ID: uuid.NewString(),
|
||||
BuyerID: buyerID,
|
||||
TargetID: targetID,
|
||||
Tier: tier.Key,
|
||||
Fee: tier.Fee,
|
||||
Paid: price,
|
||||
Status: mischiefStatusOpen,
|
||||
Signed: order.Signed,
|
||||
Source: "web",
|
||||
OrderGUID: order.GUID,
|
||||
CreatedAt: now,
|
||||
WindowEndsAt: now.Add(mischiefWindow),
|
||||
}
|
||||
if err := insertMischiefContract(c); err != nil {
|
||||
// Distinguish the one legitimate rejection from a transient write failure,
|
||||
// the same way the Matrix path does — the two want opposite handling.
|
||||
if liveMischiefForTarget(targetID) != nil {
|
||||
// A rival contract is already on this target (ours isn't: we checked
|
||||
// order_guid up top). Refund and terminally bounce; the buyer lost a
|
||||
// race, not money.
|
||||
refund()
|
||||
slog.Warn("mischief: web contract lost the race", "order", order.GUID, "target", targetID)
|
||||
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "someone already sent a monster their way"}
|
||||
}
|
||||
// A real write failure with no rival to blame. Leave the debit in place
|
||||
// and the order pending — the guid makes the next poll's re-run a no-op on
|
||||
// the money and a clean retry on the insert, which is the whole point of
|
||||
// keying on it. Refunding here would both drop a placeable hit and, worse,
|
||||
// hand out a free contract if the retry then succeeded against the credit.
|
||||
slog.Warn("mischief: web contract insert failed, will retry", "order", order.GUID, "target", targetID, "err", err)
|
||||
return mischiefWebResult{Retry: true}
|
||||
}
|
||||
|
||||
p.announceMischiefContract(c, tier)
|
||||
p.dmMischiefVictim(c, tier)
|
||||
emitMischiefContractNews(c, tier)
|
||||
p.dmMischiefWebBuyer(buyerID, c, tier)
|
||||
|
||||
return mischiefWebResult{Status: mischiefWebPlaced, Detail: fmt.Sprintf("a %s is on the way", strings.ToLower(tier.Display))}
|
||||
}
|
||||
|
||||
// mischiefBuyerMXID reconstructs the buyer's Matrix id from the username Pete
|
||||
// sent. Authentik's preferred_username is the Matrix localpart by construction
|
||||
// (verified on the MAS box), so the buyer is @<username>:<our homeserver>. We
|
||||
// lowercase because Matrix localparts are lowercase and an Authentik display of
|
||||
// the name may not be. Fails closed if the client isn't up (tests) or the name
|
||||
// is empty.
|
||||
func (p *AdventurePlugin) mischiefBuyerMXID(username string) (id.UserID, bool) {
|
||||
lp := strings.ToLower(strings.TrimSpace(username))
|
||||
if lp == "" || p.Client == nil {
|
||||
return "", false
|
||||
}
|
||||
server := p.Client.UserID.Homeserver()
|
||||
if server == "" {
|
||||
return "", false
|
||||
}
|
||||
return id.NewUserID(lp, server), true
|
||||
}
|
||||
|
||||
// dmMischiefWebBuyer tells a web buyer their hit is out, the same courtesy a
|
||||
// Matrix buyer gets in-thread. Best-effort: a buyer with no DM room open just
|
||||
// watches the storefront status instead.
|
||||
func (p *AdventurePlugin) dmMischiefWebBuyer(buyerID id.UserID, c *mischiefContract, tier mischiefTier) {
|
||||
msg := fmt.Sprintf("😈 The word's out. A **%s** is on its way to **%s** — it finds them in about %s.\n%s",
|
||||
strings.ToLower(tier.Display), p.DisplayName(c.TargetID), formatDuration(mischiefWindow),
|
||||
mischiefBuyerNote(c.Signed))
|
||||
if err := p.SendDM(buyerID, msg); err != nil {
|
||||
slog.Debug("mischief: web buyer DM failed", "buyer", buyerID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mischiefOpenContractFor resolves "@user" to the contract currently open against
|
||||
// them, or explains why the room can't act on it. A contract already claimed for
|
||||
// delivery is deliberately out of reach: the monster is in the room with them and
|
||||
// a ward bought now would have to reach back into a fight that has started.
|
||||
func (p *AdventurePlugin) mischiefOpenContractFor(ctx MessageContext, fields []string, verb string) (*mischiefContract, id.UserID, string) {
|
||||
if len(fields) < 1 {
|
||||
return nil, "", fmt.Sprintf("Usage: `!mischief %s @user`", verb)
|
||||
}
|
||||
targetID, ok := p.ResolveUser(fields[0], ctx.RoomID)
|
||||
if !ok {
|
||||
return nil, "", fmt.Sprintf("Could not resolve that user. Usage: `!mischief %s @user`", verb)
|
||||
}
|
||||
c := liveMischiefForTarget(targetID)
|
||||
if c == nil {
|
||||
return nil, targetID, fmt.Sprintf("Nobody has coin on **%s** right now.", p.DisplayName(targetID))
|
||||
}
|
||||
if c.Status != mischiefStatusOpen {
|
||||
return nil, targetID, fmt.Sprintf(
|
||||
"Too late — the thing has already found **%s**.", p.DisplayName(targetID))
|
||||
}
|
||||
return c, targetID, ""
|
||||
}
|
||||
|
||||
// mischiefBlessCmd — the helping half of the window. The fee is a pure sink (it
|
||||
// goes to the community pot, never to the purse), so a blessing can't be used to
|
||||
// route money to the target: it buys them HP, not euros.
|
||||
func (p *AdventurePlugin) mischiefBlessCmd(ctx MessageContext, fields []string) error {
|
||||
lock := p.advUserLock(ctx.Sender)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
c, targetID, reason := p.mischiefOpenContractFor(ctx, fields, "bless")
|
||||
if c == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, reason)
|
||||
}
|
||||
if c.BlessingCount >= mischiefBlessingCap {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"**%s** is already carrying every ward the town has to give (%d). The rest is up to them.",
|
||||
p.DisplayName(targetID), mischiefBlessingCap))
|
||||
}
|
||||
// Priced off the contract's current basis: warding someone against a boss costs
|
||||
// what a boss is worth, and an escalation raises the price of saving them.
|
||||
fee := mischiefBlessingFee(c.Fee)
|
||||
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < fee {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"A ward against a %s costs %s — you have %s.",
|
||||
strings.ToLower(c.Tier), fmtEuro(fee), fmtEuro(int(bal))))
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(fee), "mischief_blessing") {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.")
|
||||
}
|
||||
// The cap is enforced by the UPDATE, not by the read above — two people can
|
||||
// buy the third ward in the same second, and only one of them may have it.
|
||||
if !blessMischiefContract(c.ID) {
|
||||
p.euro.Credit(ctx.Sender, float64(fee), "mischief_blessing_refund")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Somebody got there first — the ward wasn't needed. You've been refunded.")
|
||||
}
|
||||
communityPotAdd(fee)
|
||||
trackTaxPaid(ctx.Sender, fee)
|
||||
|
||||
// Read the tally back rather than deriving it from the pre-UPDATE snapshot: two
|
||||
// blessers landing together would both compute BlessingCount+1 off a count of 0
|
||||
// and both announce "1 of 3" for a contract that is now carrying 2.
|
||||
count := c.BlessingCount + 1
|
||||
if fresh, err := mischiefContractByID(c.ID); err == nil && fresh != nil {
|
||||
count = fresh.BlessingCount
|
||||
}
|
||||
|
||||
p.announceMischiefBlessing(c, ctx.Sender, count)
|
||||
p.SendDM(targetID, fmt.Sprintf(
|
||||
"🕯️ **%s** has paid for a ward on you. Whatever's coming, I've made you harder to put down.",
|
||||
p.DisplayName(ctx.Sender)))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"🕯️ Done — **%s** goes into that fight tougher than they would have. (%d/%d wards)",
|
||||
p.DisplayName(targetID), count, mischiefBlessingCap))
|
||||
}
|
||||
|
||||
// mischiefEscalateCmd — the "helping" half. Anyone but the target can pay the
|
||||
// difference to send something worse. The money joins the payout basis, so piling
|
||||
// on raises the jackpot the target walks away with if they live: the room's cruelty
|
||||
// is also its generosity, which is the joke.
|
||||
func (p *AdventurePlugin) mischiefEscalateCmd(ctx MessageContext, fields []string) error {
|
||||
lock := p.advUserLock(ctx.Sender)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
c, targetID, reason := p.mischiefOpenContractFor(ctx, fields, "escalate")
|
||||
if c == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, reason)
|
||||
}
|
||||
if targetID == ctx.Sender {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"You don't get to raise the price on your own head. Have some dignity.")
|
||||
}
|
||||
cur, _ := mischiefTierByKey(c.Tier)
|
||||
next, ok := mischiefNextTier(c.Tier)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"It's already the worst thing in their bracket. There is nothing bigger to send.")
|
||||
}
|
||||
if c.EscalationCount > 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"Somebody has already made it worse — it's a **%s** now. Once is the limit.",
|
||||
strings.ToLower(cur.Display)))
|
||||
}
|
||||
// An escalation into boss tier is still a boss landing on this person, so it
|
||||
// answers to the same weekly limit a bought boss does. (This contract is not a
|
||||
// boss yet, so it cannot count itself.)
|
||||
now := time.Now().UTC()
|
||||
if next.Key == "boss" && mischiefBossCountSince(targetID, now.Add(-mischiefBossPerTargetWindow)) > 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"**%s** has already had a boss sent after them this week. Let them keep this one.",
|
||||
p.DisplayName(targetID)))
|
||||
}
|
||||
delta := next.Fee - cur.Fee
|
||||
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < delta {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"Bumping a %s up to a %s costs the difference: %s. You have %s.",
|
||||
strings.ToLower(cur.Display), strings.ToLower(next.Display), fmtEuro(delta), fmtEuro(int(bal))))
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(delta), "mischief_escalation") {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.")
|
||||
}
|
||||
if !escalateMischiefContract(c.ID, cur.Key, next, delta, ctx.Sender) {
|
||||
p.euro.Credit(ctx.Sender, float64(delta), "mischief_escalation_refund")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Somebody beat you to it, or the thing already left. You've been refunded.")
|
||||
}
|
||||
|
||||
purse := mischiefPurse(next.Fee, next.PayoutPct)
|
||||
p.announceMischiefEscalation(c, next, purse)
|
||||
p.SendDM(targetID, fmt.Sprintf(
|
||||
"😈 It got worse. What's coming for you is a **%s** now — somebody in town paid to upgrade it.\n"+
|
||||
"Their money's in the pot with the rest: walk away from it and you keep %s.",
|
||||
strings.ToLower(next.Display), fmtEuro(purse)))
|
||||
if c.BuyerID != ctx.Sender {
|
||||
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||
"😈 Somebody liked your idea and paid to make it worse: **%s** is getting a **%s** now.",
|
||||
p.DisplayName(targetID), strings.ToLower(next.Display)))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"😈 Paid. It's a **%s** now. If **%s** walks away from it they keep %s — you helped with that too.\n"+
|
||||
"_Nobody knows it was you — unless they survive._",
|
||||
strings.ToLower(next.Display), p.DisplayName(targetID), fmtEuro(purse)))
|
||||
}
|
||||
|
||||
func mischiefBuyerNote(signed bool) string {
|
||||
if signed {
|
||||
return "_Your name is on it. Everyone already knows._"
|
||||
@@ -653,18 +1173,77 @@ func (p *AdventurePlugin) announceMischiefContract(c *mischiefContract, tier mis
|
||||
formatDuration(time.Until(c.WindowEndsAt)), fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct))))
|
||||
}
|
||||
|
||||
// dmMischiefVictim is TwinBee telling the target that somebody wants them dead.
|
||||
// Pure flavor — the expedition is autonomous and they cannot act on it directly —
|
||||
// but it is what lets them ask the room for wards, which is the whole window.
|
||||
func (p *AdventurePlugin) dmMischiefVictim(c *mischiefContract, tier mischiefTier) {
|
||||
who := "Somebody in town"
|
||||
if c.Signed {
|
||||
who = "**" + p.DisplayName(c.BuyerID) + "**"
|
||||
}
|
||||
p.SendDM(c.TargetID, fmt.Sprintf(
|
||||
"😈 **Word's reached me, and you're not going to like it.**\n"+
|
||||
"%s has paid to have a **%s** find you out there. It's already looking. I make it about %s.\n\n"+
|
||||
"I can't call it off, and I can't come out there. What the town *can* do is ward you — "+
|
||||
"anyone who likes you can `!mischief bless %s` (%s each, up to %d).\n"+
|
||||
"Walk away from it and their money is yours: **%s**. And we all find out who paid.",
|
||||
who, strings.ToLower(tier.Display), formatDuration(time.Until(c.WindowEndsAt)),
|
||||
// The full MXID, not the display name: handleMischiefCmd splits on whitespace,
|
||||
// so a copy-pasted `bless @Misty Blue` would resolve on "@Misty" — or on
|
||||
// somebody else entirely. ResolveUser takes an "@user:server" verbatim.
|
||||
c.TargetID, fmtEuro(mischiefBlessingFee(c.Fee)), mischiefBlessingCap,
|
||||
fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct))))
|
||||
}
|
||||
|
||||
// announceMischiefBlessing — helping is public and immediate. The blesser is
|
||||
// named on the spot: there is no reason to hide having done someone a kindness,
|
||||
// and the room seeing wards go up is what pulls the next one out of somebody.
|
||||
func (p *AdventurePlugin) announceMischiefBlessing(c *mischiefContract, blesser id.UserID, count int) {
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
p.SendMessage(gr, fmt.Sprintf(
|
||||
"🕯️ **%s** puts a ward on **%s** — %d of %d. Whatever's coming for them will have to work harder.",
|
||||
p.DisplayName(blesser), p.DisplayName(c.TargetID), count, mischiefBlessingCap))
|
||||
}
|
||||
|
||||
// announceMischiefEscalation — "helping" is anonymous, exactly like the purchase
|
||||
// it piles onto, and unsealed by the same survival.
|
||||
func (p *AdventurePlugin) announceMischiefEscalation(c *mischiefContract, next mischiefTier, purse int) {
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
p.SendMessage(gr, fmt.Sprintf(
|
||||
"😈 **Somebody has made it worse.** What's hunting **%s** out there is a **%s** now.\n"+
|
||||
"They put their money in the pot to do it — so if **%s** walks away, the purse is up to %s.",
|
||||
p.DisplayName(c.TargetID), strings.ToLower(next.Display),
|
||||
p.DisplayName(c.TargetID), fmtEuro(purse)))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceMischiefSurvived(c *mischiefContract, monsterName string, purse int) {
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
// The unseal. Anonymity is the buyer's to lose, and losing it is what makes
|
||||
// a survival worth announcing.
|
||||
// a survival worth announcing. Whoever paid to make it worse loses theirs on
|
||||
// the same terms — piling on is a purchase, and it carries the same exposure.
|
||||
p.SendMessage(gr, fmt.Sprintf(
|
||||
"🛡️ **%s walked away from it.** The **%s** that came for them is dead, and %s is %s richer for the trouble.\n"+
|
||||
"The coin came from **%s**.",
|
||||
"The coin came from **%s**.%s",
|
||||
p.DisplayName(c.TargetID), monsterName, p.DisplayName(c.TargetID), fmtEuro(purse),
|
||||
p.DisplayName(c.BuyerID)))
|
||||
p.DisplayName(c.BuyerID), p.mischiefEscalatorNote(c)))
|
||||
}
|
||||
|
||||
// mischiefEscalatorNote names whoever paid to upgrade the contract, for the
|
||||
// unseal. Empty when nobody did.
|
||||
func (p *AdventurePlugin) mischiefEscalatorNote(c *mischiefContract) string {
|
||||
if c.EscalatedBy == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" **%s** paid to make it worse.", p.DisplayName(c.EscalatedBy))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceMischiefDowned(c *mischiefContract, monsterName string) {
|
||||
@@ -686,6 +1265,13 @@ func (p *AdventurePlugin) announceMischiefDowned(c *mischiefContract, monsterNam
|
||||
//
|
||||
// Deploy Pete BEFORE gogobee whenever these types change: Pete 400s an unknown
|
||||
// event_type, gogobee retries, and the bulletin parks forever.
|
||||
//
|
||||
// BULLETIN, never priority. Priority tier is the one thing that makes Pete post a
|
||||
// live Matrix beat, and TwinBee already announces every one of these in the games
|
||||
// room as it happens (announceMischiefContract / Survived / Downed) — a priority
|
||||
// fact would just read Pete's version back to the people who watched it. Bulletin
|
||||
// still carries the story to the site and the daily digest, where a roundup is
|
||||
// what it is. Same call 1cbd68a made for death and zone_first.
|
||||
|
||||
func emitMischiefContractNews(c *mischiefContract, tier mischiefTier) {
|
||||
target := charName(c.TargetID)
|
||||
@@ -702,7 +1288,7 @@ func emitMischiefContractNews(c *mischiefContract, tier mischiefTier) {
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("mischief_contract:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
||||
EventType: "mischief_contract",
|
||||
Tier: "priority",
|
||||
Tier: "bulletin",
|
||||
Subject: target,
|
||||
Opponent: buyer,
|
||||
Boss: tier.Display,
|
||||
@@ -733,7 +1319,7 @@ func emitMischiefResolvedNews(c *mischiefContract, monsterName, outcome string,
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("%s:%s:%d", eventType, eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
||||
EventType: eventType,
|
||||
Tier: "priority",
|
||||
Tier: "bulletin",
|
||||
Subject: target,
|
||||
Opponent: buyer,
|
||||
Boss: monsterName,
|
||||
|
||||
@@ -121,6 +121,15 @@ func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) {
|
||||
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)
|
||||
@@ -132,46 +141,110 @@ func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) {
|
||||
// row would otherwise be immortal: the target can never be targeted again, and
|
||||
// the buyer's money never comes back.
|
||||
//
|
||||
// The buyer is made whole in full rather than rake-charged. A stranded delivery
|
||||
// is our crash, not a bet they lost.
|
||||
// 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.euro.Credit(contract.BuyerID, float64(contract.Paid), "mischief_refund_stranded")
|
||||
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(contract.Paid)))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// fizzleMischief refunds the buyer (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.
|
||||
// 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
|
||||
}
|
||||
refund := int(float64(c.Paid) * mischiefFizzleRefund)
|
||||
rake := c.Paid - refund
|
||||
if refund > 0 {
|
||||
p.euro.Credit(c.BuyerID, float64(refund), "mischief_fizzle_refund")
|
||||
}
|
||||
if rake > 0 {
|
||||
communityPotAdd(rake)
|
||||
trackTaxPaid(c.BuyerID, rake)
|
||||
}
|
||||
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 {
|
||||
@@ -208,7 +281,8 @@ func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition)
|
||||
}
|
||||
}
|
||||
|
||||
narration, monsterName, survived := p.runMischiefInterrupt(target, exp, bracket, chain, ambush)
|
||||
narration, monsterName, survived := p.runMischiefInterrupt(
|
||||
target, exp, bracket, chain, ambush, c.BlessingCount)
|
||||
|
||||
if survived {
|
||||
p.resolveMischiefSurvived(c, tier, exp, bracket, monsterName, narration)
|
||||
@@ -219,13 +293,21 @@ func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition)
|
||||
}
|
||||
|
||||
// refundClaimedMischief unwinds a contract that was already claimed for delivery
|
||||
// but couldn't be staged. The buyer gets everything back — this is our fault,
|
||||
// not a fizzle they gambled on.
|
||||
// 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)
|
||||
p.euro.Credit(c.BuyerID, float64(c.Paid), "mischief_refund_unstageable")
|
||||
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(c.Paid)))
|
||||
"😾 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)
|
||||
}
|
||||
|
||||
@@ -243,10 +325,17 @@ func (p *AdventurePlugin) runMischiefInterrupt(
|
||||
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
|
||||
@@ -300,6 +389,57 @@ func (p *AdventurePlugin) runMischiefInterrupt(
|
||||
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
|
||||
@@ -331,11 +471,13 @@ func (p *AdventurePlugin) resolveMischiefSurvived(
|
||||
purse := mischiefPurse(c.Fee, tier.PayoutPct)
|
||||
p.euro.Credit(c.TargetID, float64(purse), "mischief_survived")
|
||||
|
||||
// Everything the buyer spent that isn't the purse — the rake, plus the whole
|
||||
// sign surcharge — is a sink. That is what makes the payout cap bite.
|
||||
// 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)
|
||||
trackTaxPaid(c.BuyerID, rake)
|
||||
trackMischiefSink(c, rake)
|
||||
}
|
||||
|
||||
// Extras by tier. Treasure rolls against the zone they're actually in — the
|
||||
@@ -370,8 +512,8 @@ func (p *AdventurePlugin) resolveMischiefSurvived(
|
||||
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.",
|
||||
p.DisplayName(c.BuyerID)))
|
||||
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 + "_")
|
||||
}
|
||||
@@ -380,6 +522,12 @@ func (p *AdventurePlugin) resolveMischiefSurvived(
|
||||
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)
|
||||
@@ -414,7 +562,7 @@ func (p *AdventurePlugin) resolveMischiefDowned(
|
||||
}
|
||||
|
||||
communityPotAdd(c.Paid)
|
||||
trackTaxPaid(c.BuyerID, c.Paid)
|
||||
trackMischiefSink(c, c.Paid)
|
||||
|
||||
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
|
||||
|
||||
@@ -439,6 +587,13 @@ func (p *AdventurePlugin) resolveMischiefDowned(
|
||||
"💀 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)
|
||||
|
||||
@@ -377,7 +377,7 @@ func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) {
|
||||
bracket := mischiefBracketZone(5)
|
||||
chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2)))
|
||||
|
||||
_, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush)
|
||||
_, 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)
|
||||
}
|
||||
@@ -492,7 +492,12 @@ func TestMischiefDelivery_FizzlesWhenTargetWentHome(t *testing.T) {
|
||||
t.Fatalf("forcedExtractExpedition: %v", err)
|
||||
}
|
||||
|
||||
p.fireMischiefDeliveries(time.Now().UTC().Add(mischiefWindow + time.Minute))
|
||||
// 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 {
|
||||
@@ -562,3 +567,363 @@ func TestMischiefDelivery_SurvivalFloorsADroppedPartyMember(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ import (
|
||||
// 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
|
||||
@@ -97,7 +107,7 @@ func (p *AdventurePlugin) backfillZoneFirsts() int {
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), f.zoneID, ts.Unix()),
|
||||
EventType: "zone_first",
|
||||
Tier: "priority",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Boss: zone.Boss.Name,
|
||||
@@ -151,7 +161,7 @@ func backfillDeaths() int {
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date),
|
||||
EventType: "death",
|
||||
Tier: "priority",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Zone: d.location,
|
||||
Level: lvl,
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.
|
||||
ragingBerserker(t, uid)
|
||||
|
||||
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
||||
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
|
||||
ragingBerserker(t, uid)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
||||
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
|
||||
}
|
||||
|
||||
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
|
||||
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ type DeathTransitionResult struct {
|
||||
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
||||
var r DeathTransitionResult
|
||||
|
||||
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && rand.Float64() < 0.33 {
|
||||
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && simFloat64() < 0.33 {
|
||||
r.Pardoned = true
|
||||
now := time.Now().UTC()
|
||||
p.Char.LastPardonUsed = &now
|
||||
|
||||
@@ -97,7 +97,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
|
||||
// Seat the whole party, leader first. A solo player is a one-seat roster and
|
||||
// takes the path they always took: one build, one INSERT, no participant rows.
|
||||
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood)
|
||||
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood, run)
|
||||
if refusal != "" {
|
||||
return p.replyDM(ctx, refusal)
|
||||
}
|
||||
@@ -119,6 +119,17 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
|
||||
}
|
||||
|
||||
// Layer-2 boss state that is spent mid-fight (Valdris's Phylactery Verses)
|
||||
// is seeded onto the fresh session ONCE, from the route the player walked to
|
||||
// get here — not on the per-round rebuild, which would resurrect spent
|
||||
// rebirths. enemyHP is the party-scaled pool persisted above. No-op for every
|
||||
// non-hooked enemy.
|
||||
if seedBossRunStatuses(sess, monster.ID, enemyHP, run) {
|
||||
if err := saveCombatSession(sess); err != nil {
|
||||
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if isBoss {
|
||||
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||||
@@ -696,6 +707,24 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
PlayerHeal: out.PlayerHeal,
|
||||
EnemySkip: out.EnemySkip,
|
||||
}
|
||||
// Revive the arcane-blaster sustained cantrip floor in the turn engine.
|
||||
// combat_engine.go:590 deals CantripPerRound flat every round, but that
|
||||
// path is the swing-based engine (SimulateCombat). Auto-resolve — the
|
||||
// live path for every expedition — runs the turn engine, where a caster
|
||||
// autocasts and never weapon-swings, so the floor was dead code: casters
|
||||
// fought at bare cantrip dice (~4d10≈22 at L20). Lift LANDED cantrip
|
||||
// damage to the floor, but only when the cast already connected
|
||||
// (eff.EnemyDamage > 0) — a whiffed Fire Bolt still whiffs, so the ~35%
|
||||
// miss variance survives and the floor doesn't become a guaranteed flat
|
||||
// hammer (that overshot to ~99%). Damage cantrips only; slot spells keep
|
||||
// their rolled damage. Only Mage/Sorcerer/Warlock carry a nonzero
|
||||
// CantripPerRound, so this is self-targeting.
|
||||
if spell.Level == 0 && eff.EnemyDamage > 0 &&
|
||||
(spell.Effect == EffectDamageAttack || spell.Effect == EffectDamageSave || spell.Effect == EffectDamageAuto) {
|
||||
if floor := ct.players[seat].Mods.CantripPerRound; floor > eff.EnemyDamage {
|
||||
eff.EnemyDamage = floor
|
||||
}
|
||||
}
|
||||
// §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
|
||||
|
||||
@@ -181,6 +181,20 @@ type CombatModifiers struct {
|
||||
SpellPreDamage int
|
||||
SpellPreDamageDesc string
|
||||
SpellEnemySkipFirst bool
|
||||
|
||||
// At-will cantrip channel. Arcane blasters (Mage/Sorcerer/Warlock) throw a
|
||||
// scaling damage cantrip EVERY round — 5e cantrips are the caster's at-will
|
||||
// floor and scale to 4 dice at L17 (Fire Bolt 4d10, Eldritch Blast 4 beams).
|
||||
// The pre-combat one-shot SpellPreDamage modelled a single leveled cast and
|
||||
// left the caster swinging a stick for the rest of the fight; that is the
|
||||
// whole of the caster T5-room wall (one burst can't finish a 65-HP monster
|
||||
// and the quarterstaff floor does nothing). CantripPerRound is dealt as flat
|
||||
// magic damage at the top of resolvePlayerSwings each round — no dice roll,
|
||||
// so the RNG stream is stable and variance stays low. 0 for non-casters, so
|
||||
// martial combat is byte-identical. Halved by enemy spell_resist like any
|
||||
// spell. CantripDesc is the narration hook (spell name).
|
||||
CantripPerRound int
|
||||
CantripDesc string
|
||||
}
|
||||
|
||||
type Combatant struct {
|
||||
@@ -428,6 +442,14 @@ type combatState struct {
|
||||
enemyRegen int // regenerate: enemy heals this much each round end
|
||||
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
|
||||
|
||||
// Phylactery Verses (T6 Valdris) — stackable rebirth. enemyReviveCharges is
|
||||
// how many times the boss still cheats death; each revives it to
|
||||
// enemyReviveHP. Seeded once at fight start from unfound Verses, round-tripped
|
||||
// through CombatStatuses. Distinct from enemySurviveArmed (a one-shot 1-HP
|
||||
// proc): a rebirth restores a meaningful pool, and there can be several.
|
||||
enemyReviveCharges int
|
||||
enemyReviveHP int
|
||||
|
||||
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
|
||||
// backed by real state.
|
||||
enemySpellResist bool // spell_resist: player spell damage against this enemy is halved
|
||||
@@ -435,6 +457,25 @@ type combatState struct {
|
||||
enemyFearImmune bool // fear_immune: player control spells (enemy-skip) fizzle against this enemy
|
||||
enemyAtkBuff int // ally_buff: flat, accumulating bonus to the enemy's attack damage
|
||||
|
||||
// Amendment (T6 Custodian) — an in-combat Layer-2 hook resolved at round end
|
||||
// by applyBossInCombatRoundEnd. enemyRewindHP is the boss's round-3 HP
|
||||
// snapshot (0 until captured); enemyRewindUsed gates the once-only rewind
|
||||
// that restores it to that snapshot when the boss crosses into phase 2. The
|
||||
// soft midnight timer past round 20 rides enemyAtkBuff. Round-tripped through
|
||||
// CombatStatuses; zero/false for every non-Custodian fight.
|
||||
enemyRewindHP int
|
||||
enemyRewindUsed bool
|
||||
|
||||
// Inversion Stitch (T6 Seamstress) — an in-combat Layer-2 hook resolved at
|
||||
// round end by applyBossInCombatRoundEnd, live only in the boss's phase 2.
|
||||
// inversionActive is the number of rounds the room stays sewn inside-out
|
||||
// (heals sting instead of mend, gated in stepPlayerActionEffect); it counts
|
||||
// down one per round end. inversionTelegraph warns the round before a pulse
|
||||
// activates, so a player who watches the tell can hold their heals. Both
|
||||
// round-trip through CombatStatuses; zero/false for every non-Seamstress fight.
|
||||
inversionActive int
|
||||
inversionTelegraph bool
|
||||
|
||||
round int
|
||||
events []CombatEvent
|
||||
|
||||
@@ -541,6 +582,31 @@ func maybeTriggerOrcRage(st *combatState, player *Combatant, phaseName string) {
|
||||
// consumes once-per-fight openers (AutoCritFirst, FirstAttackBonus,
|
||||
// AssassinateAdvantage) via st flags — extras roll vanilla.
|
||||
func resolvePlayerSwings(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
// At-will cantrip: fires once per round before the weapon swing, independent
|
||||
// of whether the swing connects (a caster who whiffs the stick still throws
|
||||
// its Fire Bolt). Flat magic damage, halved by spell_resist. 0 for
|
||||
// non-casters → skipped entirely, so martial combat draws no extra events
|
||||
// and no RNG. See CantripPerRound in CombatModifiers.
|
||||
if player.Mods.CantripPerRound > 0 && st.enemyHP > 0 {
|
||||
dmg := player.Mods.CantripPerRound
|
||||
if enemyResistsSpells(enemy, st) {
|
||||
dmg = max(1, dmg/2)
|
||||
}
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phase.Name, Actor: "player", Action: "cantrip",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: player.Mods.CantripDesc,
|
||||
})
|
||||
// Route the kill through enemyDown, not a raw HP read: a boss that cheats
|
||||
// death (survive_at_1) or holds a phylactery rebirth (T6 Valdris) must get
|
||||
// that chance even when the lethal blow is the at-will cantrip. enemyDown
|
||||
// restores its HP and returns false, so the weapon swing below resolves
|
||||
// against the revived pool. Mirrors resolvePlayerAttack's own kill routing.
|
||||
if enemyDown(st, phase.Name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if resolvePlayerAttack(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
@@ -1308,6 +1374,19 @@ func enemyDown(st *combatState, phaseName string) bool {
|
||||
})
|
||||
return false
|
||||
}
|
||||
// Phylactery Verses (T6 Valdris): a stackable rebirth. Each unfound Verse
|
||||
// left one of these charges, and each restores a real pool rather than the
|
||||
// 1-HP stay above — a full-clear explorer stripped them all and fights a
|
||||
// mortal, a skip-route fights a god who keeps getting back up.
|
||||
if st.enemyReviveCharges > 0 {
|
||||
st.enemyReviveCharges--
|
||||
st.enemyHP = max(1, st.enemyReviveHP)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "phylactery_rebirth",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,10 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
case "concentration_tick":
|
||||
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
|
||||
|
||||
case "cantrip":
|
||||
// e.Desc is the spell name (Fire Bolt / Eldritch Blast); e.Damage the hit.
|
||||
return fmt.Sprintf(pickRand(narrativeCantrip), e.Desc, e.Damage)
|
||||
|
||||
case "pet_deflect":
|
||||
return pickRand(narrativePetDeflect)
|
||||
|
||||
@@ -331,6 +335,18 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
return pickRand(narrativeSurviveArmed)
|
||||
case "survive_at_1":
|
||||
return pickRand(narrativeSurvive)
|
||||
case "phylactery_rebirth":
|
||||
return pickRand(narrativePhylacteryRebirth)
|
||||
case "amendment_rewind":
|
||||
return pickRand(narrativeAmendmentRewind)
|
||||
case "midnight_toll":
|
||||
return fmt.Sprintf(pickRand(narrativeMidnightToll), e.Damage)
|
||||
case "inversion_telegraph":
|
||||
return pickRand(narrativeInversionTelegraph)
|
||||
case "inversion_stitch":
|
||||
return pickRand(narrativeInversionStitch)
|
||||
case "heal_inverted":
|
||||
return fmt.Sprintf(pickRand(narrativeHealInverted), e.Damage)
|
||||
case "stat_drain":
|
||||
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
|
||||
case "debuff":
|
||||
@@ -546,6 +562,15 @@ var narrativeConcentrationTick = []string{
|
||||
"🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.",
|
||||
}
|
||||
|
||||
// narrativeCantrip fires each round an arcane blaster throws its at-will cantrip
|
||||
// (Fire Bolt / Eldritch Blast) before the weapon swing. %s is the spell name,
|
||||
// %d the damage — a caster's sustained floor, so it lands every round.
|
||||
var narrativeCantrip = []string{
|
||||
"✨ %s streaks out and burns home — %d damage. The at-will floor never stops.",
|
||||
"✨ A bolt of %s answers before the staff even moves, scorching for %d.",
|
||||
"✨ %s lances the enemy for %d. No incantation, no wind-up — just the steady arcane drum.",
|
||||
}
|
||||
|
||||
var narrativePetDeflect = []string{
|
||||
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
|
||||
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
|
||||
@@ -761,6 +786,52 @@ var narrativeSurvive = []string{
|
||||
"🕯️ The enemy by all rights should be down. It is, instead, very barely up.",
|
||||
}
|
||||
|
||||
// narrativePhylacteryRebirth fires when Valdris burns a Verse the player left
|
||||
// un-found: a bound rebirth spends and the lich reassembles. Each line reads as
|
||||
// "you skipped one of these" so the mechanic teaches itself over a wipe.
|
||||
var narrativePhylacteryRebirth = []string{
|
||||
"💀 The lich comes apart — and a Verse you never found sings him back together. He rises, unhurried.",
|
||||
"💀 Bone-dust swirls up off the floor and re-seats itself. A rebirth you didn't unbind just spent itself. He stands.",
|
||||
"💀 That should have been the end of him. A Verse still hums somewhere in the cathedral, and Valdris simply *begins again*.",
|
||||
}
|
||||
|
||||
// narrativeAmendmentRewind fires when the Custodian rewinds itself to its round-3
|
||||
// HP snapshot — the once-only Amendment. Each line reads as time being undone so
|
||||
// the mechanic (front-loaded burst is partly refunded) teaches itself over a run.
|
||||
var narrativeAmendmentRewind = []string{
|
||||
"🕰️ The Custodian raises a hand and *edits the last few minutes out of the record.* Wounds close in reverse; the clock-golem stands as it did rounds ago.",
|
||||
"🕰️ \"That entry is amended.\" The damage you dealt simply un-happens — the Custodian rewinds to where it was and resumes, unhurried.",
|
||||
"🕰️ Verdigris rings spin backward. Time you spent hurting it is refunded to the golem; it returns to its round-three self and keeps working.",
|
||||
}
|
||||
|
||||
// narrativeMidnightToll fires past round 20 — the soft closing-time timer, the
|
||||
// Custodian's Attack climbing each round a stalled fight refuses to end.
|
||||
var narrativeMidnightToll = []string{
|
||||
"🔔 A bell tolls somewhere above. Closing time — the Custodian's swings come harder. (+%d attack)",
|
||||
"🔔 The hour is nearly spent, and so is its patience; each blow lands with more weight now. (+%d attack)",
|
||||
}
|
||||
|
||||
// narrativeInversionTelegraph fires one round before an Inversion Stitch pulse —
|
||||
// the Seamstress's tell. It reads as a warning so a player learns to hold heals.
|
||||
var narrativeInversionTelegraph = []string{
|
||||
"🧵 The Seamstress draws a thread taut and the room *shivers* — walls flexing toward inside-out. Whatever you were about to mend, hold it. (next round, healing turns against you)",
|
||||
"🧵 A seam in the air puckers. The geometry is about to flip; a cure cast into it will run backward. (inversion incoming next round)",
|
||||
}
|
||||
|
||||
// narrativeInversionStitch fires when a pulse activates — the room is sewn
|
||||
// inside-out and healing now wounds for the pulse's duration.
|
||||
var narrativeInversionStitch = []string{
|
||||
"🧵 The stitch pulls through. The room is inside-out now — for a moment, to heal is to hurt.",
|
||||
"🧵 Everything turns wrong-way-round. Mending and wounding have swapped ends of the needle.",
|
||||
}
|
||||
|
||||
// narrativeHealInverted fires each time a heal lands during an active pulse: the
|
||||
// cure runs backward and stings instead. Teaches the mechanic on the spot.
|
||||
var narrativeHealInverted = []string{
|
||||
"🧵 The heal runs backward through the inverted room — the cure opens the wound it meant to close. (%d damage)",
|
||||
"🧵 Healing turns against its target in the sewn-inside-out air; the mend lands as a sting. (%d damage)",
|
||||
}
|
||||
|
||||
var narrativeStatDrain = []string{
|
||||
"🩸 The enemy saps your strength — your swings feel heavier, weaker. (-%d hit damage)",
|
||||
"🩸 Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)",
|
||||
|
||||
@@ -51,7 +51,7 @@ func fightRoster(sender id.UserID) []id.UserID {
|
||||
// The enemy is built once. Every seat's build derives the identical stat block
|
||||
// from (monster, tier, dmMood); only the player half varies.
|
||||
func (p *AdventurePlugin) buildFightSeats(
|
||||
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int,
|
||||
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int, run *DungeonRun,
|
||||
) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) {
|
||||
skip := func(uid id.UserID, why string) {
|
||||
if uid == sender {
|
||||
@@ -157,6 +157,13 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
// 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)
|
||||
|
||||
// Fold in any Layer-2 pre-combat boss mechanic before this enemy is used to
|
||||
// persist the initial HP pool. partyCombatantsForSession re-applies the same
|
||||
// modifier on every round's rebuild; doing it here keeps the persisted stat
|
||||
// block consistent with the fight the engine will actually run. No-op for
|
||||
// every non-hooked enemy.
|
||||
applyBossRunModifiers(monster.ID, enemy, run)
|
||||
return seats, enemy, senderSkip, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) {
|
||||
fightTestChar(t, solo, 30)
|
||||
|
||||
seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0)
|
||||
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" || skip != "" {
|
||||
t.Fatalf("solo fight refused: %s / %s", refusal, skip)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
|
||||
|
||||
roster := []id.UserID{leader, downed, standing}
|
||||
seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
leader, roster, dndBestiary["goblin"], 1, 0)
|
||||
leader, roster, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("party refused over a downed member: %s", refusal)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
|
||||
|
||||
// The one who was left behind typed `!fight` too, and silence is not an answer.
|
||||
_, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats(
|
||||
downed, roster, dndBestiary["goblin"], 1, 0)
|
||||
downed, roster, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("a downed member must not refuse the party's fight: %s", refusal)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
|
||||
roster := []id.UserID{leader, member}
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0)
|
||||
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0, nil)
|
||||
if len(seats) != 0 || refusal == "" {
|
||||
t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
|
||||
t.Errorf("the leader should be told to rest, got %q", refusal)
|
||||
}
|
||||
|
||||
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0)
|
||||
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0, nil)
|
||||
if !strings.Contains(refusal, "leader") {
|
||||
t.Errorf("the member should be told it is the leader holding things up, got %q", refusal)
|
||||
}
|
||||
|
||||
@@ -215,12 +215,44 @@ type CombatStatuses struct {
|
||||
EnemyRegen int `json:"enemy_regen,omitempty"`
|
||||
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
|
||||
|
||||
// Phylactery Verses (Tier-6 postgame, Valdris Ascendant). Unlike the
|
||||
// proc-armed EnemySurviveArmed one-shot, this is a *count* of rebirths seeded
|
||||
// once at fight start (seedBossRunStatuses) from how many of the zone's secret
|
||||
// Verses the player left un-found. Each consumed rebirth (enemyDown) revives
|
||||
// the boss to EnemyReviveHP. Both fields are frozen at seed time except
|
||||
// EnemyReviveCharges, which decrements as rebirths are spent — so they must
|
||||
// round-trip through combatState to survive a suspend/resume. Zero for every
|
||||
// other enemy, so omitempty keeps them off every non-Valdris row.
|
||||
EnemyReviveCharges int `json:"enemy_revive_charges,omitempty"`
|
||||
EnemyReviveHP int `json:"enemy_revive_hp,omitempty"`
|
||||
|
||||
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||
// EnemyRevealNext is a one-shot; the other three persist for the fight.
|
||||
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
|
||||
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
|
||||
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
|
||||
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
|
||||
|
||||
// Amendment (Tier-6 postgame, The Custodian of the Last Hour). An in-combat
|
||||
// Layer-2 hook resolved at round end (applyBossInCombatRoundEnd), not proc-
|
||||
// armed: EnemyRewindHP snapshots the boss's HP at the end of round 3 (0 until
|
||||
// captured); when the boss then crosses into phase 2 the hook restores it to
|
||||
// that snapshot exactly once and sets EnemyRewindUsed. Both round-trip through
|
||||
// combatState so the once-only rewind survives a suspend/resume. Zero/false
|
||||
// for every other enemy. The soft midnight timer past round 20 rides the
|
||||
// existing EnemyAtkBuff, so it needs no field of its own.
|
||||
EnemyRewindHP int `json:"enemy_rewind_hp,omitempty"`
|
||||
EnemyRewindUsed bool `json:"enemy_rewind_used,omitempty"`
|
||||
|
||||
// Inversion Stitch (Tier-6 postgame, The Seamstress). An in-combat Layer-2
|
||||
// hook resolved at round end (applyBossInCombatRoundEnd), live only in the
|
||||
// boss's phase 2. InversionActive is the rounds-remaining of the inside-out
|
||||
// pulse during which player heals sting instead of mend (gated in
|
||||
// stepPlayerActionEffect); InversionTelegraph is the one-round warning before
|
||||
// a pulse activates. Both round-trip through combatState so a suspend/resume
|
||||
// can't lose or replay a pulse mid-fight. Zero/false for every other enemy.
|
||||
InversionActive int `json:"inversion_active,omitempty"`
|
||||
InversionTelegraph bool `json:"inversion_telegraph,omitempty"`
|
||||
}
|
||||
|
||||
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
|
||||
@@ -450,6 +482,9 @@ const combatSessionCols = `
|
||||
|
||||
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
||||
func newCombatSessionID() string {
|
||||
if simSeedOn() {
|
||||
return simHexToken()
|
||||
}
|
||||
var b [8]byte
|
||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||
// Vanishingly unlikely; fall through with a zeroed prefix.
|
||||
|
||||
@@ -211,6 +211,13 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
// until every seat is built.
|
||||
applySeatWeights(players, levels, companions)
|
||||
|
||||
// Layer-2 pre-combat boss mechanics: fold in any run-state-derived
|
||||
// adjustment (e.g. Aurvandryx's Greed Tax) before the party HP scaling. This
|
||||
// is re-derived every round like everything else here; its inputs are frozen
|
||||
// for a terminal boss fight, so the result is stable. No-op for every
|
||||
// non-hooked enemy.
|
||||
applyBossRunModifiers(monster.ID, &enemy, run)
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -363,12 +363,20 @@ func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatan
|
||||
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
|
||||
enemyRegen: sess.Statuses.EnemyRegen,
|
||||
enemySurviveArmed: sess.Statuses.EnemySurviveArmed,
|
||||
enemyReviveCharges: sess.Statuses.EnemyReviveCharges,
|
||||
enemyReviveHP: sess.Statuses.EnemyReviveHP,
|
||||
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||
enemySpellResist: sess.Statuses.EnemySpellResist,
|
||||
enemyRevealNext: sess.Statuses.EnemyRevealNext,
|
||||
enemyFearImmune: sess.Statuses.EnemyFearImmune,
|
||||
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
|
||||
rng: rng,
|
||||
// Amendment (T6 Custodian) — round-3 snapshot + once-only rewind.
|
||||
enemyRewindHP: sess.Statuses.EnemyRewindHP,
|
||||
enemyRewindUsed: sess.Statuses.EnemyRewindUsed,
|
||||
// Inversion Stitch (T6 Seamstress) — phase-2 heal-inverting pulses.
|
||||
inversionActive: sess.Statuses.InversionActive,
|
||||
inversionTelegraph: sess.Statuses.InversionTelegraph,
|
||||
rng: rng,
|
||||
}
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)
|
||||
@@ -568,10 +576,22 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
st.enemyHP = max(0, st.enemyHP-enemyDmg)
|
||||
}
|
||||
if eff.PlayerHeal > 0 {
|
||||
// Respect any max_hp_drain monster ability — a drained player can't be
|
||||
// healed back past the lowered ceiling.
|
||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||
if st.inversionActive > 0 {
|
||||
// Inversion Stitch (T6 Seamstress phase 2): the room is sewn inside-out,
|
||||
// so the cure lands as a wound. Floored at 1 so a player is never killed
|
||||
// by their own heal — the Seamstress's own blows do the finishing; the
|
||||
// sting just denies the sustain and softens the seat for them.
|
||||
st.playerHP = max(1, st.playerHP-eff.PlayerHeal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "heal_inverted",
|
||||
Damage: eff.PlayerHeal, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
} else {
|
||||
// Respect any max_hp_drain monster ability — a drained player can't be
|
||||
// healed back past the lowered ceiling.
|
||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||
}
|
||||
}
|
||||
// §1 — heal somebody else. The caster's cursor stays where it is; only the
|
||||
// target's HP moves.
|
||||
@@ -582,14 +602,27 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
// 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,
|
||||
})
|
||||
if st.inversionActive > 0 {
|
||||
// Inversion Stitch: the ally-heal wounds the friend it was meant to
|
||||
// mend. Floored at 1 like the self-heal sting above — the sting denies
|
||||
// the sustain, it does not kill.
|
||||
before := tgt.playerHP
|
||||
tgt.playerHP = max(1, tgt.playerHP-eff.AllyHeal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "heal_inverted",
|
||||
Damage: before - tgt.playerHP, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
|
||||
Seat: eff.AllySeat, Desc: eff.Label,
|
||||
})
|
||||
} else {
|
||||
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
|
||||
@@ -840,7 +873,12 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
|
||||
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
// Route the kill through enemyDown, not a raw HP read: a boss that cheats
|
||||
// death (survive_at_1) or holds a phylactery rebirth (T6 Valdris) must get
|
||||
// that chance even when the lethal blow is a lingering concentration pulse.
|
||||
// enemyDown restores its HP and returns false, so the next seat's pulse (or
|
||||
// the following round) resolves against the revived pool.
|
||||
if enemyDown(st, CombatPhaseRoundEnd) {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
@@ -872,6 +910,14 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
// Tier-6 in-combat Layer-2 boss hooks (Amendment): round-3 HP snapshot +
|
||||
// once-only phase-2 rewind + soft midnight timer, resolved on the round that
|
||||
// just finished. A no-op for every enemy but the hooked bosses, and only
|
||||
// while the enemy still stands, so it is safe to call unconditionally here
|
||||
// after the round's damage has settled.
|
||||
if st.enemyHP > 0 {
|
||||
applyBossInCombatRoundEnd(st, te.sess.EnemyID, te.enemy.Stats.MaxHP)
|
||||
}
|
||||
st.round++
|
||||
// Initiative is re-rolled each round, so the next round's order is derived
|
||||
// here — off st.round, since commit has not yet pushed it onto the session.
|
||||
@@ -926,10 +972,16 @@ func (te *turnEngine) commit() {
|
||||
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
|
||||
s.EnemyRegen = st.enemyRegen
|
||||
s.EnemySurviveArmed = st.enemySurviveArmed
|
||||
s.EnemyReviveCharges = st.enemyReviveCharges
|
||||
s.EnemyReviveHP = st.enemyReviveHP
|
||||
s.EnemySpellResist = st.enemySpellResist
|
||||
s.EnemyRevealNext = st.enemyRevealNext
|
||||
s.EnemyFearImmune = st.enemyFearImmune
|
||||
s.EnemyAtkBuff = st.enemyAtkBuff
|
||||
s.EnemyRewindHP = st.enemyRewindHP
|
||||
s.EnemyRewindUsed = st.enemyRewindUsed
|
||||
s.InversionActive = st.inversionActive
|
||||
s.InversionTelegraph = st.inversionTelegraph
|
||||
|
||||
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
||||
}
|
||||
|
||||
@@ -275,3 +275,57 @@ func TestTurnEngine_CommitPersistsSeatZeroNotTheCursor(t *testing.T) {
|
||||
t.Error("seat 1's consumed Lucky reroll leaked onto the session row")
|
||||
}
|
||||
}
|
||||
|
||||
// A lingering concentration pulse that lands the killing blow must still give a
|
||||
// revive-armed boss (survive_at_1 / T6 Valdris's phylactery rebirth) its chance
|
||||
// to stand back up — the round-end tick routes the kill through enemyDown, not a
|
||||
// raw enemyHP<=0 read. Regression for the concentration-bypass gap found in the
|
||||
// P8 Layer-2 review.
|
||||
func TestTurnEngine_ConcentrationKillHonorsRebirth(t *testing.T) {
|
||||
// A charged rebirth: the pulse drops the enemy, a charge spends, it revives.
|
||||
sess := turnSession(CombatPhaseRoundEnd, 500, 30)
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.concentrationDmg = 100 // lethal against 30 HP
|
||||
te.st.enemyReviveCharges = 1
|
||||
te.st.enemyReviveHP = 40
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if !sess.IsActive() {
|
||||
t.Fatalf("a concentration kill ended the fight (%q) while a rebirth charge was armed", sess.Status)
|
||||
}
|
||||
if sess.EnemyHP != 40 {
|
||||
t.Errorf("revived EnemyHP = %d, want the 40-HP revive pool", sess.EnemyHP)
|
||||
}
|
||||
if sess.Statuses.EnemyReviveCharges != 0 {
|
||||
t.Errorf("post-revive charges = %d, want 0 (one spent)", sess.Statuses.EnemyReviveCharges)
|
||||
}
|
||||
rebirths := 0
|
||||
for _, ev := range sess.TurnLog {
|
||||
if ev.Action == "phylactery_rebirth" {
|
||||
rebirths++
|
||||
}
|
||||
}
|
||||
if rebirths != 1 {
|
||||
t.Errorf("phylactery_rebirth events = %d, want 1", rebirths)
|
||||
}
|
||||
|
||||
// With no charge left, the same pulse ends the fight cleanly (the win path
|
||||
// is not broken by the enemyDown routing).
|
||||
mortal := turnSession(CombatPhaseRoundEnd, 500, 30)
|
||||
p2 := basePlayer()
|
||||
e2 := baseEnemy()
|
||||
te2 := resumeTurnEngine(mortal, []*Combatant{&p2}, &e2, combatSessionStepRNG(mortal, enemySeat))
|
||||
te2.st.concentrationDmg = 100
|
||||
if _, err := te2.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te2.commit()
|
||||
if mortal.Status != CombatStatusWon {
|
||||
t.Errorf("charge-less concentration kill status = %q, want %q", mortal.Status, CombatStatusWon)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,21 @@ type ThomCraftRecipe struct {
|
||||
// inventory items consumed (§8.2 Drow Poison Blade). 0 = none.
|
||||
BladeWeaponCount int
|
||||
OutputName string
|
||||
OutputType string // material | item | weapon | armor | accessory | consumable
|
||||
OutputType string // material | item | weapon | armor | accessory | consumable | magic_item
|
||||
OutputTier int
|
||||
OutputValue int64
|
||||
Description string
|
||||
DiscoveryHint string // shown when newly discovered via !lore
|
||||
// OutputSkillSource, when set, is stamped on the crafted AdvItem's
|
||||
// SkillSource — used for OutputType "magic_item" so the result equips and
|
||||
// resolves against the registry exactly like a looted magic item
|
||||
// ("magic_item:<id>").
|
||||
OutputSkillSource string
|
||||
Description string
|
||||
DiscoveryHint string // shown when newly discovered via !lore
|
||||
// AlwaysKnown recipes are never gated behind !lore discovery — they're the
|
||||
// T6 signature-item pity path, craftable the moment a player holds enough
|
||||
// of the zone's crafting anchor. They stay hidden from the recipe book
|
||||
// until the player actually has an anchor in hand (see renderRecipeBook).
|
||||
AlwaysKnown bool
|
||||
}
|
||||
|
||||
var thomCraftRecipes = []ThomCraftRecipe{
|
||||
@@ -115,6 +125,83 @@ var thomCraftRecipes = []ThomCraftRecipe{
|
||||
Description: "Coated blade — sleep-on-crit (DC 14 CON save) for 3 combats. (Effect wired in R6 polish.)",
|
||||
DiscoveryHint: "Thom turns a glass vial in the lamplight. \"Drow brew, drawn down to a thin coat. Bring me any blade you trust — sword, dagger, scimitar, makes no odds. The poison picks the edge.\"",
|
||||
},
|
||||
|
||||
// ── T6 signature-item pity path ────────────────────────────────────────
|
||||
// AlwaysKnown: no !lore gate. Each consumes 5 of the zone's crafting anchor
|
||||
// (one guaranteed per clear) so a bad-luck run of the 4–6% signature drops
|
||||
// still converts ~5 clears into a guaranteed best-in-slot. Output is a real
|
||||
// magic_item — same SkillSource shape the loot path stamps — so it equips
|
||||
// and carries its postgameSignatureEffects delta.
|
||||
{
|
||||
ID: "anchor_crown_of_the_patient_king",
|
||||
Name: "Crown of the Patient King",
|
||||
Ingredients: map[string]int{
|
||||
"Verse Bound Folio": 5,
|
||||
},
|
||||
OutputName: "Crown of the Patient King",
|
||||
OutputType: "magic_item",
|
||||
OutputTier: 5,
|
||||
OutputValue: 8000,
|
||||
OutputSkillSource: "magic_item:crown_of_the_patient_king",
|
||||
AlwaysKnown: true,
|
||||
Description: "Legendary crown of Valdris — the patient plan, worn on the head. (Pity craft: 5 Ossuary clears.)",
|
||||
},
|
||||
{
|
||||
ID: "anchor_aegis_of_the_first_scale",
|
||||
Name: "Aegis of the First Scale",
|
||||
Ingredients: map[string]int{
|
||||
"Cooling Ember Of Aurvandryx": 5,
|
||||
},
|
||||
OutputName: "Aegis of the First Scale",
|
||||
OutputType: "magic_item",
|
||||
OutputTier: 5,
|
||||
OutputValue: 9000,
|
||||
OutputSkillSource: "magic_item:aegis_of_the_first_scale",
|
||||
AlwaysKnown: true,
|
||||
Description: "Legendary breastplate of the Ember Before Fire — best armor in the game. (Pity craft: 5 First Hoard clears.)",
|
||||
},
|
||||
{
|
||||
ID: "anchor_needle_of_the_seam",
|
||||
Name: "Needle of the Seam",
|
||||
Ingredients: map[string]int{
|
||||
"Spool Of Undone Geometry": 5,
|
||||
},
|
||||
OutputName: "Needle of the Seam",
|
||||
OutputType: "magic_item",
|
||||
OutputTier: 5,
|
||||
OutputValue: 8000,
|
||||
OutputSkillSource: "magic_item:needle_of_the_seam",
|
||||
AlwaysKnown: true,
|
||||
Description: "Legendary needle-dagger of the Seamstress — best caster weapon. (Pity craft: 5 Unplace clears.)",
|
||||
},
|
||||
{
|
||||
ID: "anchor_halo_of_the_sunken_dawn",
|
||||
Name: "Halo of the Sunken Dawn",
|
||||
Ingredients: map[string]int{
|
||||
"Ember Of The Drowned Star": 5,
|
||||
},
|
||||
OutputName: "Halo of the Sunken Dawn",
|
||||
OutputType: "magic_item",
|
||||
OutputTier: 5,
|
||||
OutputValue: 8000,
|
||||
OutputSkillSource: "magic_item:halo_of_the_sunken_dawn",
|
||||
AlwaysKnown: true,
|
||||
Description: "Legendary halo of Seraphel — the first true healer's crown. (Pity craft: 5 Drowned Star clears.)",
|
||||
},
|
||||
{
|
||||
ID: "anchor_the_second_hand",
|
||||
Name: "The Second Hand",
|
||||
Ingredients: map[string]int{
|
||||
"Unspent Hour": 5,
|
||||
},
|
||||
OutputName: "The Second Hand",
|
||||
OutputType: "magic_item",
|
||||
OutputTier: 5,
|
||||
OutputValue: 8000,
|
||||
OutputSkillSource: "magic_item:the_second_hand",
|
||||
AlwaysKnown: true,
|
||||
Description: "Legendary sidearm of the Last Meridian — best martial sidearm. (Pity craft: 5 Last Meridian clears.)",
|
||||
},
|
||||
}
|
||||
|
||||
// bladeWeaponKeywords are the name-substrings that qualify an AdvItem
|
||||
@@ -140,6 +227,13 @@ func isBladeWeapon(it AdvItem) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// recipeCraftable reports whether a player may craft a recipe: either they've
|
||||
// discovered it via !lore, or it's an AlwaysKnown pity recipe (which skips
|
||||
// discovery entirely — the crafting anchor it needs is the real gate).
|
||||
func recipeCraftable(known map[string]bool, r ThomCraftRecipe) bool {
|
||||
return r.AlwaysKnown || known[r.ID]
|
||||
}
|
||||
|
||||
// findRecipeByID returns the recipe with the given ID.
|
||||
func findRecipeByID(id string) (ThomCraftRecipe, bool) {
|
||||
for _, r := range thomCraftRecipes {
|
||||
@@ -410,7 +504,7 @@ func (p *AdventurePlugin) handleCraftCmd(ctx MessageContext, args string) error
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"No recipe matches '%s'. Try `!craft list` to see what you've discovered.", args))
|
||||
}
|
||||
if !known[recipe.ID] {
|
||||
if !recipeCraftable(known, recipe) {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"_Thom: \"Doesn't ring a bell. If you don't know the recipe, I don't either.\"_\n\nDiscover it via `!lore` first.")
|
||||
}
|
||||
@@ -427,12 +521,18 @@ func renderRecipeBook(userID id.UserID, known map[string]bool) string {
|
||||
items, _ := loadAdvInventory(userID)
|
||||
have := inventoryByName(items)
|
||||
|
||||
knownCount := 0
|
||||
shownCount := 0
|
||||
for _, r := range thomCraftRecipes {
|
||||
if !known[r.ID] {
|
||||
// AlwaysKnown pity recipes only surface once the player actually holds
|
||||
// one of the crafting anchor — no need to spoil T6 chase items to a
|
||||
// level-3 player. Discovered recipes always show.
|
||||
switch {
|
||||
case known[r.ID]:
|
||||
case r.AlwaysKnown && recipeHasAnyIngredient(have, r):
|
||||
default:
|
||||
continue
|
||||
}
|
||||
knownCount++
|
||||
shownCount++
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", r.Name))
|
||||
var parts []string
|
||||
ready := true
|
||||
@@ -463,11 +563,18 @@ func renderRecipeBook(userID id.UserID, known map[string]bool) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if knownCount == 0 {
|
||||
if shownCount == 0 {
|
||||
sb.WriteString("_You don't know any recipes yet. Try `!lore` at Thom Krooke's._\n\n")
|
||||
}
|
||||
|
||||
hidden := len(thomCraftRecipes) - knownCount
|
||||
// Only discoverable (non-AlwaysKnown) recipes count toward the !lore hint;
|
||||
// pity recipes are never "discovered", so they'd make the count never zero.
|
||||
hidden := 0
|
||||
for _, r := range thomCraftRecipes {
|
||||
if !r.AlwaysKnown && !known[r.ID] {
|
||||
hidden++
|
||||
}
|
||||
}
|
||||
if hidden > 0 {
|
||||
sb.WriteString(fmt.Sprintf("_%d undiscovered recipe(s). Use `!lore` to dig._", hidden))
|
||||
} else {
|
||||
@@ -476,6 +583,18 @@ func renderRecipeBook(userID id.UserID, known map[string]bool) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// recipeHasAnyIngredient reports whether the player holds at least one unit of
|
||||
// any of a recipe's named ingredients — the gate for surfacing an AlwaysKnown
|
||||
// pity recipe in the book.
|
||||
func recipeHasAnyIngredient(have map[string]int, r ThomCraftRecipe) bool {
|
||||
for ing := range r.Ingredients {
|
||||
if have[ing] > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// inventoryByName counts items grouped by name.
|
||||
func inventoryByName(items []AdvItem) map[string]int {
|
||||
out := map[string]int{}
|
||||
@@ -517,10 +636,11 @@ func (p *AdventurePlugin) executeCraft(userID id.UserID, r ThomCraftRecipe) erro
|
||||
}
|
||||
|
||||
out := AdvItem{
|
||||
Name: r.OutputName,
|
||||
Type: r.OutputType,
|
||||
Tier: r.OutputTier,
|
||||
Value: r.OutputValue,
|
||||
Name: r.OutputName,
|
||||
Type: r.OutputType,
|
||||
Tier: r.OutputTier,
|
||||
Value: r.OutputValue,
|
||||
SkillSource: r.OutputSkillSource,
|
||||
}
|
||||
if err := addAdvInventoryItem(userID, out); err != nil {
|
||||
return p.SendDM(userID, "Crafted item couldn't be deposited. Tell an admin.")
|
||||
@@ -600,6 +720,11 @@ func (p *AdventurePlugin) handleLoreCmd(ctx MessageContext) error {
|
||||
|
||||
var undiscovered []ThomCraftRecipe
|
||||
for _, r := range thomCraftRecipes {
|
||||
// AlwaysKnown recipes are never in the discovery pool — they can't be
|
||||
// "found" and shouldn't dilute the odds of finding the real ones.
|
||||
if r.AlwaysKnown {
|
||||
continue
|
||||
}
|
||||
if !known[r.ID] {
|
||||
undiscovered = append(undiscovered, r)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ func TestRecipeRegistry_IngredientsExistInResources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for _, r := range thomCraftRecipes {
|
||||
// AlwaysKnown pity recipes consume zone-loot crafting anchors, not
|
||||
// gather-resource materials — validated separately below.
|
||||
if r.AlwaysKnown {
|
||||
continue
|
||||
}
|
||||
for ing := range r.Ingredients {
|
||||
if !known[ing] {
|
||||
t.Errorf("recipe %s references unknown ingredient %q", r.ID, ing)
|
||||
@@ -26,12 +31,59 @@ func TestRecipeRegistry_IngredientsExistInResources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPityRecipes_AnchorsAndOutputsResolve — the T6 signature-item pity
|
||||
// recipes consume a zone-loot UniqueAlways crafting anchor and emit a real
|
||||
// registry magic item. Validate both ends: every ingredient name matches a
|
||||
// UniqueAlways zone-loot drop's inventory name, and every OutputSkillSource
|
||||
// points at a magicItemRegistry ID.
|
||||
func TestPityRecipes_AnchorsAndOutputsResolve(t *testing.T) {
|
||||
anchorNames := map[string]bool{}
|
||||
for _, z := range allZones() {
|
||||
for _, e := range z.Loot {
|
||||
if e.UniqueAlways {
|
||||
anchorNames[titleCaseUnderscored(e.ItemID)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
pity := 0
|
||||
for _, r := range thomCraftRecipes {
|
||||
if !r.AlwaysKnown {
|
||||
continue
|
||||
}
|
||||
pity++
|
||||
for ing := range r.Ingredients {
|
||||
if !anchorNames[ing] {
|
||||
t.Errorf("pity recipe %s ingredient %q is not a zone-loot anchor name", r.ID, ing)
|
||||
}
|
||||
}
|
||||
if r.OutputType != "magic_item" {
|
||||
t.Errorf("pity recipe %s OutputType = %q, want magic_item", r.ID, r.OutputType)
|
||||
}
|
||||
id, ok := strings.CutPrefix(r.OutputSkillSource, "magic_item:")
|
||||
if !ok {
|
||||
t.Errorf("pity recipe %s OutputSkillSource %q missing magic_item: prefix", r.ID, r.OutputSkillSource)
|
||||
continue
|
||||
}
|
||||
if _, ok := magicItemRegistry[id]; !ok {
|
||||
t.Errorf("pity recipe %s output %q not in magicItemRegistry", r.ID, id)
|
||||
}
|
||||
}
|
||||
if pity != 5 {
|
||||
t.Errorf("expected 5 T6 pity recipes, found %d", pity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecipeRegistry_OutputValuesMatchTier — outputs should land in §8.1
|
||||
// rarity brackets given their tier (rare 100–300, very rare 500–1200,
|
||||
// epic 500–1200 in §8.1 — recipes here lean tier 3–4, so 200..1200 is the
|
||||
// reasonable band).
|
||||
func TestRecipeRegistry_OutputValuesInBand(t *testing.T) {
|
||||
for _, r := range thomCraftRecipes {
|
||||
// Pity recipes emit Legendary magic items priced well above the §8.1
|
||||
// crafting band by design — excluded from this invariant.
|
||||
if r.AlwaysKnown {
|
||||
continue
|
||||
}
|
||||
if r.OutputValue < 200 || r.OutputValue > 1200 {
|
||||
t.Errorf("recipe %s output value %d outside band [200,1200] (tier %d)",
|
||||
r.ID, r.OutputValue, r.OutputTier)
|
||||
|
||||
@@ -160,6 +160,23 @@ func (p *AdventurePlugin) expeditionCmdList(ctx MessageContext, c *DnDCharacter)
|
||||
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||||
}
|
||||
// Post-game zones under the "Beyond the Map" divider (see zoneCmdList).
|
||||
if pg := postgameZones(); len(pg) > 0 {
|
||||
unlocked, reason := postgameUnlocked(ctx.Sender, c.Level)
|
||||
b.WriteString("\n— Beyond the Map —\n")
|
||||
if !unlocked {
|
||||
b.WriteString(fmt.Sprintf("_%s_\n", reason))
|
||||
}
|
||||
for j, z := range pg {
|
||||
if unlocked {
|
||||
b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d+_ `!expedition start %s`\n",
|
||||
len(zones)+j+1, z.Display, int(z.Tier), z.LevelMin, z.ID))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("🔒 %s — _T%d, L%d+_\n", z.Display, int(z.Tier), z.LevelMin))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||||
}
|
||||
}
|
||||
if exp, isLeader, _ := activeExpeditionFor(ctx.Sender); exp != nil {
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
tail := "Use `!expedition status` or `!expedition abandon`."
|
||||
@@ -295,9 +312,12 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
if strings.EqualFold(zoneTok, "epilogue") {
|
||||
return p.handleEpilogueEncounter(ctx)
|
||||
}
|
||||
available := zonesForLevel(c.Level)
|
||||
available := availableZonesFor(ctx.Sender, c.Level)
|
||||
zoneID, ok := resolveZoneInput(zoneTok, available)
|
||||
if !ok {
|
||||
if reason := postgameLockReason(zoneTok, ctx.Sender, c.Level); reason != "" {
|
||||
return p.SendDM(ctx.Sender, reason)
|
||||
}
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Unknown zone for your level. Try `!expedition list`.")
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ package plugin
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/flavor"
|
||||
@@ -75,7 +74,7 @@ func resolveCombatInterrupt(
|
||||
rollFn func() int,
|
||||
) (CombatInterruptKind, int) {
|
||||
if rollFn == nil {
|
||||
rollFn = func() int { return rand.IntN(20) + 1 }
|
||||
rollFn = func() int { return simIntN(20) + 1 }
|
||||
}
|
||||
r := rollFn()
|
||||
mod := tier
|
||||
@@ -249,7 +248,7 @@ func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
|
||||
if tier < 1 {
|
||||
tier = 1
|
||||
}
|
||||
dmg := 1 + rand.IntN(4) + m.AttackBonus/2
|
||||
dmg := 1 + simIntN(4) + m.AttackBonus/2
|
||||
floor := tier
|
||||
if floorOverride >= 0 {
|
||||
floor = floorOverride
|
||||
@@ -484,7 +483,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
return
|
||||
}
|
||||
chance := rollPatrolChance(exp.ThreatLevel)
|
||||
if chance <= 0 || rand.Float64() > chance {
|
||||
if chance <= 0 || simFloat64() > chance {
|
||||
return
|
||||
}
|
||||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
|
||||
|
||||
@@ -112,6 +112,56 @@ func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacte
|
||||
// AutoCritFirst is already a one-shot bool.
|
||||
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
|
||||
// The passive heal triggers first since both use the same threshold.
|
||||
// cantripDice is the 5e at-will cantrip die progression (Fire Bolt / Eldritch
|
||||
// Blast): 1 die L1–4, 2 at L5, 3 at L11, 4 at L17. Drives the per-round arcane
|
||||
// blaster damage (CantripPerRound) that models a caster's sustained at-will
|
||||
// floor — see CombatModifiers.CantripPerRound.
|
||||
func cantripDice(level int) int {
|
||||
switch {
|
||||
case level >= 17:
|
||||
return 4
|
||||
case level >= 11:
|
||||
return 3
|
||||
case level >= 5:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// casterBlasterFloor gives the arcane blasters (Mage/Sorcerer/Warlock) their
|
||||
// shared sustained-DPS floor at T5. Two knobs, both LEVEL-SCALED so the L20
|
||||
// floor lift stays negligible at low tiers — a flat +40 HP doubled a L1 mage
|
||||
// and facerolled T5, which the class-balance guardrail (dnd_class_balance_test)
|
||||
// correctly rejected.
|
||||
//
|
||||
// - Cantrip: kill-speed is the T5 currency (fights are truncation-bound), so
|
||||
// the primary lever is damage. Multiplicative form — the spell modifier
|
||||
// (INT for Mage, CHA for Sorcerer/Warlock) rides EVERY die, matching 5e
|
||||
// Agonizing Blast. cantripDice scales 1→4 across levels.
|
||||
// - Survival: just enough Defense (scaled by level) that the caster lives long
|
||||
// enough for the cantrip to connect the kill — NOT a tank rider. This adds
|
||||
// Defense only (no HP add); calcDamage's diminishing returns keep the L20
|
||||
// +20 Def from becoming a wall.
|
||||
//
|
||||
// casterCantripBase / casterDefPerLevel are the caster tuning dials; see the
|
||||
// rebaseline plan for the sweep that set them. The cantrip floor only becomes a
|
||||
// live lever once combat_cmd.go bridges CantripPerRound into the turn engine
|
||||
// (the swing engine, combat_engine.go:590, is not the auto-resolve path). Mage
|
||||
// and Sorcerer take casterCantripBase; Warlock passes 0 — its bare-dice cantrip
|
||||
// plus its structural edge already lands it mid-band, so an added floor would
|
||||
// overshoot the 45 ceiling.
|
||||
const (
|
||||
casterCantripBase = 3 // per-die base before the ability modifier rides in
|
||||
casterDefPerLevel = 1 // ~+20 Def at L20, +1 at L1
|
||||
)
|
||||
|
||||
func casterBlasterFloor(stats *CombatStats, mods *CombatModifiers, level, abilityMod, base int, cantrip string) {
|
||||
mods.CantripPerRound = cantripDice(level) * (base + clampNonNeg(abilityMod))
|
||||
mods.CantripDesc = cantrip
|
||||
stats.Defense += casterDefPerLevel * level
|
||||
}
|
||||
|
||||
func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
|
||||
switch c.Class {
|
||||
case ClassFighter:
|
||||
@@ -127,18 +177,23 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
// re-tune in a follow-up if their win curves drift after this.
|
||||
switch {
|
||||
case c.Level >= 20:
|
||||
mods.ExtraAttacks += 3
|
||||
mods.ExtraAttacks += 2 // rebaseline: ceiling nerf — 3 swings at L20, was 4 (the engine-ceiling faceroll)
|
||||
case c.Level >= 11:
|
||||
mods.ExtraAttacks += 2
|
||||
case c.Level >= 5:
|
||||
mods.ExtraAttacks += 1
|
||||
}
|
||||
stats.AttackBonus -= 2 // rebaseline: sub-swing to-hit trim — 3 swings lands the Fighter in the ~60 band
|
||||
case ClassRogue:
|
||||
mods.AutoCritFirst = true
|
||||
if c.Level >= 5 { // rebaseline: 2nd swing (was 1) fixes the 1-swing action-economy floor at T5
|
||||
mods.ExtraAttacks += 1
|
||||
}
|
||||
stats.AttackBonus -= 3 // rebaseline: to-hit trim so the 2nd swing lands the Rogue in band, not the +75pp nuke
|
||||
// Phase 2 class-balance: rogue's once-per-fight auto-crit goes stale
|
||||
// at high tiers (T5 mean trails leaders by ~10pp pre-tune). Add a
|
||||
// modest steady-DPS rider so post-opener rounds aren't pure attrition.
|
||||
mods.DamageBonus += 0.05
|
||||
mods.DamageBonus += -0.10 // rebaseline: fine-trim the 2-swing Rogue down into the ~60 band
|
||||
// Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
|
||||
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
|
||||
// AutoCritFirst + DamageBonus alone left the rogue's defining
|
||||
@@ -154,6 +209,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
mods.SneakAttackDie += sneakDice
|
||||
case ClassMage:
|
||||
stats.AttackBonus++
|
||||
// At-will Fire Bolt + level-scaled survival — the sustained arcane floor.
|
||||
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.INT), casterCantripBase, "Fire Bolt")
|
||||
// Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
|
||||
// per round. A modest damage rider lifts weapon hits (DamageBonus does
|
||||
// not multiply queued SpellPreDamage — that path is its own field).
|
||||
@@ -190,10 +247,19 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
mods.ExtraAttacks += 1
|
||||
}
|
||||
case ClassDruid:
|
||||
// Wild Resilience — multiplicative, so it stacks cleanly with the
|
||||
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
|
||||
// by DerivePlayerStats before passives run.
|
||||
// Multiplicative, so it stacks cleanly with the subclass DamageReduct
|
||||
// riders (DamageReduct is initialized to 1.0 by DerivePlayerStats before
|
||||
// passives run). NOTE the player-defender direction: DamageReduct feeds
|
||||
// calcDamage as a defense multiplier, so <1 = MORE damage taken. This
|
||||
// line is therefore a mild survival trim in the same direction as the
|
||||
// rebaseline *0.2 below — not the damage cut the "Wild Resilience" name
|
||||
// suggests. Left as-is because the rebaseline sweep is tuned to it.
|
||||
mods.DamageReduct *= 0.95
|
||||
// rebaseline: the Druid wins T5 rooms on the survival tiebreak, immune to
|
||||
// every damage lever. DamageReduct is a defense multiplier (calcDamage) —
|
||||
// <1 = take MORE damage. Combined with a damage trim to pull it to band.
|
||||
mods.DamageReduct *= 0.2
|
||||
mods.DamageBonus += -0.20
|
||||
// Phase 3 class-balance: druid was the only caster chassis with a
|
||||
// purely defensive passive, and the off-tier numbers showed it —
|
||||
// L1/T2 mean 0.04 vs Mage 0.27. Mirror the other caster bursts so
|
||||
@@ -219,6 +285,7 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
stats.AttackBonus++
|
||||
mods.DamageBonus += 0.05
|
||||
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||
mods.DamageReduct *= 0.4 // rebaseline: Bard also wins T5 on the survival tiebreak — take more damage to reach band
|
||||
case ClassSorcerer:
|
||||
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
|
||||
// spellcasting stat. Floors at the flat base for low-CHA builds.
|
||||
@@ -231,6 +298,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
// touching the +0.05 rider that already saturates at high tier.
|
||||
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||
mods.DamageBonus += 0.05
|
||||
stats.AttackBonus++ // rebaseline: Sorcerer lagged the other blasters — match their +1 to-hit
|
||||
// At-will Fire Bolt + level-scaled survival — sustained arcane floor.
|
||||
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), casterCantripBase, "Fire Bolt")
|
||||
case ClassWarlock:
|
||||
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
|
||||
// the Warlock chassis read mid-pack at T5 (0.52) pre-tune. Eldritch
|
||||
@@ -240,6 +310,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
mods.DamageBonus += 0.12
|
||||
stats.AttackBonus++
|
||||
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||
// At-will Eldritch Blast + level-scaled survival — sustained arcane floor.
|
||||
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), 0, "Eldritch Blast")
|
||||
case ClassPaladin:
|
||||
// Class-identity audit (2026-05-16) — Divine Smite as actual
|
||||
// per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a
|
||||
@@ -249,8 +321,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
// down so an extra-attack paladin doesn't trivialize every fight.
|
||||
// Rides DivineStrikePerHit (already in the weapon-hit damage path).
|
||||
// Previous FlatDmgStart opener felt like Lay on Hands, not Smite.
|
||||
smite := 3 + c.Level/3
|
||||
smite := 4 + c.Level/2 // rebaseline: bigger Divine Smite lifts the Paladin from floor into band
|
||||
mods.DivineStrikePerHit += smite
|
||||
mods.DamageReduct *= 0.9 // rebaseline: small survival trim between the two integer smite steps for a ~60 landing
|
||||
if c.Level >= 5 {
|
||||
mods.ExtraAttacks += 1
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -117,7 +116,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
|
||||
before := c.HPCurrent
|
||||
if !hpFull {
|
||||
conMod := abilityModifier(c.CON)
|
||||
healDie := 1 + rand.IntN(6) // 1d6
|
||||
healDie := 1 + simIntN(6) // 1d6
|
||||
heal := healDie + conMod
|
||||
if heal < 1 {
|
||||
heal = 1
|
||||
|
||||
@@ -3,7 +3,6 @@ package plugin
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -128,7 +127,7 @@ func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLev
|
||||
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
|
||||
// Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage.
|
||||
func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifiers, enemy *CombatStats, slot, charLevel int) {
|
||||
roll := 1 + rand.IntN(20)
|
||||
roll := 1 + simIntN(20)
|
||||
isCrit := roll == 20
|
||||
isFumble := roll == 1
|
||||
if isFumble || (!isCrit && roll+atk < enemy.AC) {
|
||||
@@ -158,7 +157,7 @@ func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifier
|
||||
// future multi-enemy combat (Phase 11+) but is not consulted here.
|
||||
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||
saveMod := enemySpellSaveMod(enemy)
|
||||
saveRoll := 1 + rand.IntN(20)
|
||||
saveRoll := 1 + simIntN(20)
|
||||
saved := saveRoll+saveMod >= dc
|
||||
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
||||
if saved {
|
||||
@@ -182,7 +181,7 @@ func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, ch
|
||||
}
|
||||
total := 0
|
||||
for i := 0; i < darts; i++ {
|
||||
total += 1 + rand.IntN(4) + 1
|
||||
total += 1 + simIntN(4) + 1
|
||||
}
|
||||
mods.SpellPreDamage += total
|
||||
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, total)
|
||||
@@ -223,7 +222,7 @@ func enemySpellSaveMod(enemy *CombatStats) int {
|
||||
// double damage (5e: paralyzed creatures auto-crit on melee hits).
|
||||
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||
saveMod := enemySpellSaveMod(enemy)
|
||||
saveRoll := 1 + rand.IntN(20)
|
||||
saveRoll := 1 + simIntN(20)
|
||||
if saveRoll+saveMod >= dc {
|
||||
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
||||
return
|
||||
@@ -382,7 +381,7 @@ func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) in
|
||||
if supreme {
|
||||
heal += faces
|
||||
} else {
|
||||
heal += 1 + rand.IntN(faces)
|
||||
heal += 1 + simIntN(faces)
|
||||
}
|
||||
}
|
||||
heal += abilityModifier(c.WIS)
|
||||
@@ -418,7 +417,7 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
|
||||
}
|
||||
total := flat
|
||||
for i := 0; i < dice; i++ {
|
||||
total += 1 + rand.IntN(faces)
|
||||
total += 1 + simIntN(faces)
|
||||
}
|
||||
if total < 1 {
|
||||
total = 1
|
||||
|
||||
@@ -224,7 +224,14 @@ func TestApplyClassPassives(t *testing.T) {
|
||||
wantFlatStart int
|
||||
wantInitBias float64
|
||||
}{
|
||||
{ClassFighter, 0.05, 0, false, 0, 1.0, 0, 0},
|
||||
// Fighter-ceiling rebaseline (2026-07-16): Fighter picked up a -2 to-hit
|
||||
// sub-swing trim; Rogue's steady rider flipped +0.05→-0.10 and it took a
|
||||
// -3 to-hit trim (both offset a new 2nd swing gated at L5, not seen here);
|
||||
// Druid took a survival trim (DR 0.95→0.19) + -0.20 damage; Bard a DR 0.4
|
||||
// survival trim; Sorcerer a +1 to-hit to match the blasters; Paladin a
|
||||
// 0.9 DR survival trim. Caster CantripPerRound / Defense adds are
|
||||
// not asserted here.
|
||||
{ClassFighter, 0.05, -2, false, 0, 1.0, 0, 0},
|
||||
// Phase 2 class-balance rebalance: rogue picked up +5% damage,
|
||||
// Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's
|
||||
// burst now also scales with level, and Warlock picked up +1 attack.
|
||||
@@ -233,7 +240,7 @@ func TestApplyClassPassives(t *testing.T) {
|
||||
// Phase 3 class-balance: Druid picked up a WIS-scaled FlatDmgStart burst
|
||||
// (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5
|
||||
// (5 + 1 + clamp(mod(CHA=10)=0) = 6).
|
||||
{ClassRogue, 0.05, 0, true, 0, 1.0, 0, 0},
|
||||
{ClassRogue, -0.10, -3, true, 0, 1.0, 0, 0},
|
||||
{ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
|
||||
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
|
||||
// Class-identity audit (2026-05-16): Ranger Hunter's Mark is now
|
||||
@@ -243,11 +250,11 @@ func TestApplyClassPassives(t *testing.T) {
|
||||
// FlatDmgStart compensation riders are gone; +1 to-hit stays on
|
||||
// Ranger as the "read prey tells" half.
|
||||
{ClassRanger, 0, 1, false, 0, 1.0, 0, 0},
|
||||
{ClassDruid, 0, 0, false, 0, 0.95, 1, 0},
|
||||
{ClassBard, 0.05, 1, false, 0, 1.0, 1, 1},
|
||||
{ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0},
|
||||
{ClassDruid, -0.20, 0, false, 0, 0.95 * 0.2, 1, 0},
|
||||
{ClassBard, 0.05, 1, false, 0, 0.4, 1, 1},
|
||||
{ClassSorcerer, 0.05, 1, false, 0, 1.0, 6, 0},
|
||||
{ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0},
|
||||
{ClassPaladin, 0, 0, false, 0, 1.0, 0, 0},
|
||||
{ClassPaladin, 0, 0, false, 0, 0.9, 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
stats := CombatStats{AttackBonus: 5}
|
||||
|
||||
@@ -32,6 +32,15 @@ const (
|
||||
ZoneDragonsLair ZoneID = "dragons_lair"
|
||||
ZoneAbyssPortal ZoneID = "abyss_portal"
|
||||
|
||||
// Tier 6 "Mythic" post-game zones. Gated behind postgameUnlocked (both
|
||||
// T5 bosses beaten + level ≥ 18), never surfaced by zonesForLevel. See
|
||||
// postgame_zones.go and gogobee_postgame_zones_plan.md.
|
||||
ZoneOssuaryAscendant ZoneID = "ossuary_ascendant"
|
||||
ZoneFirstHoard ZoneID = "first_hoard"
|
||||
ZoneUnplace ZoneID = "unplace"
|
||||
ZoneDrownedStar ZoneID = "drowned_star"
|
||||
ZoneLastMeridian ZoneID = "last_meridian"
|
||||
|
||||
// ZoneArena is a synthetic ID — never registered via registerZone, so
|
||||
// !zone enter can't reach it. It exists to route renderBossOutcome's
|
||||
// twinBeeLine calls when an arena fight (resolveArenaBoss, post-L2)
|
||||
@@ -51,6 +60,10 @@ const (
|
||||
ZoneTierJourneyman ZoneTier = 3
|
||||
ZoneTierVeteran ZoneTier = 4
|
||||
ZoneTierLegendary ZoneTier = 5
|
||||
// ZoneTierMythic — Tier 6 post-game. Earned, not leveled: unlocked by
|
||||
// postgameUnlocked (both T5 bosses beaten + level ≥ 18), excluded from
|
||||
// zonesForLevel unconditionally.
|
||||
ZoneTierMythic ZoneTier = 6
|
||||
)
|
||||
|
||||
// ZoneEnemy is a roster entry. BestiaryID must resolve via dndBestiary
|
||||
@@ -83,6 +96,7 @@ type ZoneLootEntry struct {
|
||||
ItemID string
|
||||
DropChance float64 // 0..1; ignored if UniqueAlways
|
||||
UniqueAlways bool // always drops; used for quest items
|
||||
BossOnly bool // only rolls on the zone-boss kill (signature items)
|
||||
Note string // free-text descriptor (unique-item flavor)
|
||||
}
|
||||
|
||||
@@ -172,6 +186,13 @@ func zonesForLevel(dndLevel int) []ZoneDefinition {
|
||||
var out []ZoneDefinition
|
||||
for _, id := range zoneOrder {
|
||||
z := dndZoneRegistry[id]
|
||||
// Tier 6 (Mythic post-game) is never level-gated in. It has its own
|
||||
// unlock (postgameUnlocked) and surfaces only via postgameZones().
|
||||
// Note: maxTier = playerTier+2 has no hard clamp, so a high-level
|
||||
// player would otherwise reach a T6 zone here — exclude explicitly.
|
||||
if z.Tier >= ZoneTierMythic {
|
||||
continue
|
||||
}
|
||||
if int(z.Tier) <= maxTier {
|
||||
out = append(out, z)
|
||||
}
|
||||
|
||||
@@ -118,6 +118,25 @@ func (p *AdventurePlugin) zoneCmdList(ctx MessageContext, c *DnDCharacter) error
|
||||
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||||
}
|
||||
// Post-game zones render under a divider once any exist. Unlocked players
|
||||
// see enterable entries (indexed continuing from the list above, matching
|
||||
// availableZonesFor); locked players see aspiration + the unlock hint.
|
||||
if pg := postgameZones(); len(pg) > 0 {
|
||||
unlocked, reason := postgameUnlocked(ctx.Sender, c.Level)
|
||||
b.WriteString("\n— Beyond the Map —\n")
|
||||
if !unlocked {
|
||||
b.WriteString(fmt.Sprintf("_%s_\n", reason))
|
||||
}
|
||||
for j, z := range pg {
|
||||
if unlocked {
|
||||
b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d+_ `!zone enter %s`\n",
|
||||
len(zones)+j+1, z.Display, int(z.Tier), z.LevelMin, z.ID))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("🔒 %s — _T%d, L%d+_\n", z.Display, int(z.Tier), z.LevelMin))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||||
}
|
||||
}
|
||||
if active, isLeader, _ := activeZoneRunFor(ctx.Sender); active != nil {
|
||||
zone := zoneOrFallback(active.ZoneID)
|
||||
tail := "Use `!zone status` or `!zone abandon`."
|
||||
@@ -200,9 +219,12 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
|
||||
"🛌 You're still resting — %s remaining. The dungeon won't go anywhere.",
|
||||
formatRespecDuration(remaining)))
|
||||
}
|
||||
available := zonesForLevel(c.Level)
|
||||
available := availableZonesFor(ctx.Sender, c.Level)
|
||||
id, ok := resolveZoneInput(rest, available)
|
||||
if !ok {
|
||||
if reason := postgameLockReason(rest, ctx.Sender, c.Level); reason != "" {
|
||||
return p.SendDM(ctx.Sender, reason)
|
||||
}
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Unknown zone for your level. Try `!zone list` to see what's available.")
|
||||
}
|
||||
@@ -762,7 +784,7 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if granted := p.rollZoneLoot(ctx.Sender, run, zone); len(granted) > 0 {
|
||||
if granted := p.rollZoneLoot(ctx.Sender, run, zone, !midZone); len(granted) > 0 {
|
||||
b.WriteString("**Loot:**\n")
|
||||
for _, id := range granted {
|
||||
b.WriteString("• " + id + "\n")
|
||||
|
||||
@@ -357,7 +357,7 @@ func (p *AdventurePlugin) resolveTrapRoomLegacy(userID id.UserID, run *DungeonRu
|
||||
// to a single "coins" item with rolled value; everything else becomes a
|
||||
// treasure-tier inventory item with a tier-derived placeholder value
|
||||
// (zone equipment registry wiring is a later content phase).
|
||||
func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone ZoneDefinition) []string {
|
||||
func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone ZoneDefinition, bossCleared bool) []string {
|
||||
rng := rand.New(rand.NewPCG(uint64(zoneSelectorHash(run.RunID, run.CurrentRoom)), 0x100712))
|
||||
// DM mood tilts probabilistic drop chances. UniqueAlways entries are
|
||||
// untouched — those are story drops, not flavor for the DM to gate.
|
||||
@@ -367,6 +367,13 @@ func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone Z
|
||||
}
|
||||
var granted []string
|
||||
for _, entry := range zone.Loot {
|
||||
// BossOnly (signature) drops only roll on the true zone-boss / whole-zone
|
||||
// clear. rollZoneLoot fires on every region completion of a multi-region
|
||||
// zone; without this gate a T6 signature item would get four roll chances
|
||||
// per run instead of one at the boss.
|
||||
if entry.BossOnly && !bossCleared {
|
||||
continue
|
||||
}
|
||||
if !entry.UniqueAlways {
|
||||
chance := entry.DropChance * moodMult
|
||||
if rng.Float64() > chance {
|
||||
@@ -404,6 +411,15 @@ func zoneLootToInventory(entry ZoneLootEntry, zone ZoneDefinition, rng *rand.Ran
|
||||
Value: int64(val),
|
||||
}, true
|
||||
}
|
||||
// Registry-backed loot (T6 signature items and any future magic-item drops)
|
||||
// materializes as a real equippable magic_item — the SkillSource tag is what
|
||||
// the equip path and magicItemEffectFor key off. Plain named entries (legacy
|
||||
// zone signature items not in the registry) fall through to treasure below.
|
||||
if mi, ok := magicItemRegistry[entry.ItemID]; ok {
|
||||
item := magicItemSell(mi)
|
||||
item.SkillSource = "magic_item:" + mi.ID
|
||||
return item, true
|
||||
}
|
||||
tier := int(zone.Tier)
|
||||
displayName := titleCaseUnderscored(entry.ItemID)
|
||||
value := int64(50 * tier * tier) // T1=50, T2=200, T3=450, T4=800, T5=1250
|
||||
|
||||
@@ -507,6 +507,33 @@ func TestZoneTrapRoom_AntimagicFieldNoHPLoss(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneLootToInventory_RegistryItemBecomesMagicItem — a signature drop whose
|
||||
// ItemID is in the magic-item registry materializes as an equippable magic_item
|
||||
// carrying the "magic_item:<id>" SkillSource, not generic treasure.
|
||||
func TestZoneLootToInventory_RegistryItemBecomesMagicItem(t *testing.T) {
|
||||
zone, ok := getZone(ZoneFirstHoard)
|
||||
if !ok {
|
||||
t.Fatal("first_hoard zone not registered")
|
||||
}
|
||||
rng := newDeterministicRNG(t, "sig-loot")
|
||||
entry := ZoneLootEntry{ItemID: "aegis_of_the_first_scale", DropChance: 1.0, BossOnly: true}
|
||||
item, ok := zoneLootToInventory(entry, zone, rng)
|
||||
if !ok {
|
||||
t.Fatal("zoneLootToInventory returned ok=false for a registry item")
|
||||
}
|
||||
if item.Type != "magic_item" {
|
||||
t.Errorf("Type = %q, want magic_item", item.Type)
|
||||
}
|
||||
if item.SkillSource != "magic_item:aegis_of_the_first_scale" {
|
||||
t.Errorf("SkillSource = %q, want magic_item:aegis_of_the_first_scale", item.SkillSource)
|
||||
}
|
||||
// A plain non-registry entry still falls through to treasure.
|
||||
plain, _ := zoneLootToInventory(ZoneLootEntry{ItemID: "cooling_ember_of_aurvandryx", UniqueAlways: true}, zone, rng)
|
||||
if plain.Type != "treasure" {
|
||||
t.Errorf("crafting anchor Type = %q, want treasure", plain.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollZoneLoot_BossLootDrops(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@zone-loot:example")
|
||||
@@ -517,7 +544,7 @@ func TestRollZoneLoot_BossLootDrops(t *testing.T) {
|
||||
zone, _ := getZone(ZoneCryptValdris)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
granted := p.rollZoneLoot(uid, run, zone)
|
||||
granted := p.rollZoneLoot(uid, run, zone, true)
|
||||
// Crypt of Valdris has UniqueAlways "valdris_phylactery_shard" + always-drop coins.
|
||||
foundShard := false
|
||||
foundCoins := false
|
||||
|
||||
@@ -358,7 +358,10 @@ func pickLootEntry(zone map[LootTier][]ZoneLootDrop, tier LootTier, rng *rand.Ra
|
||||
// while production paths use the package-global generator.
|
||||
func rngFloat(rng *rand.Rand) float64 {
|
||||
if rng == nil {
|
||||
return rand.Float64()
|
||||
// Auto-resolve rooms pass nil. simFloat64 routes to the seeded combat
|
||||
// stream when the sim is seeding, else the package global — so prod
|
||||
// (never seeded) stays byte-identical while sim room combat pairs.
|
||||
return simFloat64()
|
||||
}
|
||||
return rng.Float64()
|
||||
}
|
||||
@@ -368,7 +371,7 @@ func rngIntN(rng *rand.Rand, n int) int {
|
||||
return 0
|
||||
}
|
||||
if rng == nil {
|
||||
return rand.IntN(n)
|
||||
return simIntN(n)
|
||||
}
|
||||
return rng.IntN(n)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,16 @@ func zoneRoomEntryPool(zoneID ZoneID) []string {
|
||||
return append(append([]string{}, flavor.RoomEntryDragonsLair...), flavor.RoomEntryGeneric...)
|
||||
case ZoneAbyssPortal:
|
||||
return append(append([]string{}, flavor.RoomEntryAbyssPortal...), flavor.RoomEntryGeneric...)
|
||||
case ZoneOssuaryAscendant:
|
||||
return append(append([]string{}, flavor.RoomEntryOssuaryAscendant...), flavor.RoomEntryGeneric...)
|
||||
case ZoneFirstHoard:
|
||||
return append(append([]string{}, flavor.RoomEntryFirstHoard...), flavor.RoomEntryGeneric...)
|
||||
case ZoneUnplace:
|
||||
return append(append([]string{}, flavor.RoomEntryUnplace...), flavor.RoomEntryGeneric...)
|
||||
case ZoneDrownedStar:
|
||||
return append(append([]string{}, flavor.RoomEntryDrownedStar...), flavor.RoomEntryGeneric...)
|
||||
case ZoneLastMeridian:
|
||||
return append(append([]string{}, flavor.RoomEntryLastMeridian...), flavor.RoomEntryGeneric...)
|
||||
}
|
||||
return flavor.RoomEntryGeneric
|
||||
}
|
||||
@@ -120,6 +130,16 @@ func bossEntryPool(zoneID ZoneID) []string {
|
||||
return flavor.BossEntryInfernax
|
||||
case ZoneAbyssPortal:
|
||||
return flavor.BossEntryBelaxath
|
||||
case ZoneOssuaryAscendant:
|
||||
return flavor.BossEntryValdrisAscendant
|
||||
case ZoneFirstHoard:
|
||||
return flavor.BossEntryAurvandryx
|
||||
case ZoneUnplace:
|
||||
return flavor.BossEntrySeamstress
|
||||
case ZoneDrownedStar:
|
||||
return flavor.BossEntrySeraphel
|
||||
case ZoneLastMeridian:
|
||||
return flavor.BossEntryCustodian
|
||||
}
|
||||
return flavor.BossEntryGeneric
|
||||
}
|
||||
@@ -182,6 +202,16 @@ func zoneLorePool(zoneID ZoneID) []string {
|
||||
return append(append([]string{}, flavor.LoreLinesDragonsLair...), flavor.LoreLines...)
|
||||
case ZoneAbyssPortal:
|
||||
return append(append([]string{}, flavor.LoreLinesAbyssPortal...), flavor.LoreLines...)
|
||||
case ZoneOssuaryAscendant:
|
||||
return append(append([]string{}, flavor.LoreLinesOssuaryAscendant...), flavor.LoreLines...)
|
||||
case ZoneFirstHoard:
|
||||
return append(append([]string{}, flavor.LoreLinesFirstHoard...), flavor.LoreLines...)
|
||||
case ZoneUnplace:
|
||||
return append(append([]string{}, flavor.LoreLinesUnplace...), flavor.LoreLines...)
|
||||
case ZoneDrownedStar:
|
||||
return append(append([]string{}, flavor.LoreLinesDrownedStar...), flavor.LoreLines...)
|
||||
case ZoneLastMeridian:
|
||||
return append(append([]string{}, flavor.LoreLinesLastMeridian...), flavor.LoreLines...)
|
||||
}
|
||||
return flavor.LoreLines
|
||||
}
|
||||
@@ -211,6 +241,16 @@ func bossSignaturePool(zoneID ZoneID) []string {
|
||||
return flavor.InfernaxSignatureCallouts
|
||||
case ZoneAbyssPortal:
|
||||
return flavor.BelaxathSignatureCallouts
|
||||
case ZoneOssuaryAscendant:
|
||||
return flavor.ValdrisAscendantSignatureCallouts
|
||||
case ZoneFirstHoard:
|
||||
return flavor.AurvandryxSignatureCallouts
|
||||
case ZoneUnplace:
|
||||
return flavor.SeamstressSignatureCallouts
|
||||
case ZoneDrownedStar:
|
||||
return flavor.SeraphelSignatureCallouts
|
||||
case ZoneLastMeridian:
|
||||
return flavor.CustodianSignatureCallouts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -236,6 +276,16 @@ func bossPhaseTwoPool(zoneID ZoneID) []string {
|
||||
return flavor.InfernaxPhaseTwoLines
|
||||
case ZoneAbyssPortal:
|
||||
return flavor.BelaxathPhaseTwoLines
|
||||
case ZoneOssuaryAscendant:
|
||||
return flavor.ValdrisAscendantPhaseTwoLines
|
||||
case ZoneFirstHoard:
|
||||
return flavor.AurvandryxPhaseTwoLines
|
||||
case ZoneUnplace:
|
||||
return flavor.SeamstressPhaseTwoLines
|
||||
case ZoneDrownedStar:
|
||||
return flavor.SeraphelPhaseTwoLines
|
||||
case ZoneLastMeridian:
|
||||
return flavor.CustodianPhaseTwoLines
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -175,6 +175,9 @@ func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
|
||||
|
||||
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
|
||||
func newRunID() string {
|
||||
if simSeedOn() {
|
||||
return simHexToken()
|
||||
}
|
||||
var b [8]byte
|
||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||
// Fall back to math/rand if /dev/urandom is unavailable.
|
||||
@@ -219,6 +222,15 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Tier 6 zones are excluded from zonesForLevel by design; they're
|
||||
// admitted here only through the post-game unlock (both T5 bosses beaten
|
||||
// + level ≥ floor). This is the shared engine safety net — command sites
|
||||
// give the friendly reason before reaching here.
|
||||
if isPostgameZone(zoneID) {
|
||||
if ok, _ := postgameUnlocked(userID, dndLevel); ok {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return nil, ErrZoneTierLocked
|
||||
}
|
||||
@@ -231,7 +243,11 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
}
|
||||
|
||||
if rng == nil {
|
||||
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
||||
if simSeedOn() {
|
||||
rng = simZoneRNG()
|
||||
} else {
|
||||
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
||||
}
|
||||
}
|
||||
seq := generateRoomSequence(zone, rng)
|
||||
|
||||
|
||||
@@ -128,10 +128,10 @@ func TestZoneRegistry_LootDropChances(t *testing.T) {
|
||||
func TestZoneRegistry_RoomCountSane(t *testing.T) {
|
||||
// Long-expedition plan §2 widens the bands per tier: T1 12–14 up to
|
||||
// T5 ~36–44. The guard floor stays at 5 (no zone should ever drop
|
||||
// below that) and the ceiling tracks the T5 target.
|
||||
// below that) and the ceiling tracks the T6 post-game target of 44–52.
|
||||
for _, z := range allZones() {
|
||||
if z.MinRooms < 5 || z.MaxRooms > 44 || z.MinRooms > z.MaxRooms {
|
||||
t.Errorf("zone %s rooms %d-%d outside design (5-44, min<=max)",
|
||||
if z.MinRooms < 5 || z.MaxRooms > 52 || z.MinRooms > z.MaxRooms {
|
||||
t.Errorf("zone %s rooms %d-%d outside design (5-52, min<=max)",
|
||||
z.ID, z.MinRooms, z.MaxRooms)
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ func TestZoneRegistry_LevelRangeMatchesTier(t *testing.T) {
|
||||
ZoneTierJourneyman: {5, 8},
|
||||
ZoneTierVeteran: {8, 12},
|
||||
ZoneTierLegendary: {12, 20},
|
||||
ZoneTierMythic: {18, 20}, // post-game: gated by unlock, not level band
|
||||
}
|
||||
for _, z := range allZones() {
|
||||
want, ok := expected[z.Tier]
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -413,6 +416,146 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
|
||||
return balance
|
||||
}
|
||||
|
||||
// HasExternalTx reports whether a money move with this externalID has already
|
||||
// been applied. It lets a retrying web caller tell a first attempt from a replay:
|
||||
// on a first attempt the affordability gate must run, but on a retry of an order
|
||||
// already debited the gate must be skipped — the debit is a settled fact, and
|
||||
// re-reading the now-lower balance would wrongly bounce a hit the buyer paid for.
|
||||
func (p *EuroPlugin) HasExternalTx(externalID string) bool {
|
||||
if externalID == "" {
|
||||
return false
|
||||
}
|
||||
var one int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT 1 FROM euro_transactions WHERE external_id = ? LIMIT 1`, externalID).Scan(&one)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
db.Exec("euro: log transaction",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,11 @@ var phase1TierCenterline = map[ZoneTier]int{
|
||||
ZoneTierJourneyman: 9,
|
||||
ZoneTierVeteran: 13,
|
||||
ZoneTierLegendary: 17,
|
||||
// Tier 6 post-game runs at the cap. These in-process matrices tune the
|
||||
// T1–T5 global levers; T6 rows are simmed and logged for reference but
|
||||
// carry no band assertion here — T6 balance is owned by the remote P7
|
||||
// sweep (n=750, control arm) per gogobee_postgame_zones_plan.md §4.
|
||||
ZoneTierMythic: 20,
|
||||
}
|
||||
|
||||
// TestExpeditionBalance_Phase1_FullMatrix is the Phase 1 baseline-
|
||||
|
||||
@@ -375,6 +375,12 @@ func isBoredomDriven(userID id.UserID, now time.Time) bool {
|
||||
func pickBoredomZone(uid id.UserID, level int) (ZoneDefinition, bool) {
|
||||
var inBand, nonRaid []ZoneDefinition
|
||||
for _, z := range allZones() {
|
||||
// Tier 6 post-game zones are opt-in endgame — a bored character never
|
||||
// wanders into one on autopilot, even once unlocked. This picker walks
|
||||
// allZones() directly (not zonesForLevel), so exclude T6 explicitly.
|
||||
if z.Tier >= ZoneTierMythic {
|
||||
continue
|
||||
}
|
||||
if level < z.LevelMin || level > z.LevelMax {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -137,7 +137,16 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
|
||||
}
|
||||
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
if !zoneOpenToLevel(exp.ZoneID, c.Level) {
|
||||
// Tier 6 zones are excluded from zonesForLevel by design, so zoneOpenToLevel
|
||||
// would refuse every seat; they carry their own post-game unlock instead.
|
||||
// Non-postgame zones keep the plain level gate.
|
||||
if isPostgameZone(exp.ZoneID) {
|
||||
// A party can't smuggle an ungated member into a Tier 6 zone: every seat
|
||||
// must have cleared the post-game unlock on their own.
|
||||
if ok, reason := postgameUnlocked(ctx.Sender, c.Level); !ok {
|
||||
return p.SendDM(ctx.Sender, reason)
|
||||
}
|
||||
} else if !zoneOpenToLevel(exp.ZoneID, c.Level) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"**%s** is beyond you for now — come back at a higher level.", zone.Display))
|
||||
}
|
||||
|
||||
@@ -136,6 +136,30 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SeedPostgameUnlock makes the synthetic user satisfy postgameUnlocked's
|
||||
// T5-clear gate by writing two completed, boss-defeated dnd_expedition rows
|
||||
// (dragons_lair + abyss_portal) into the sim's throwaway DB. Without this the
|
||||
// P1 gate in startZoneRun rejects every T6 zone ("did not persist after start")
|
||||
// and the sim can't reach post-game content. Sim/test-only — the live gate is
|
||||
// unchanged. Idempotent enough for a fresh per-run DB; call once after
|
||||
// BuildCharacter/PrepareRealCharacter when the target zone IsPostgameZone.
|
||||
func (s *SimRunner) SeedPostgameUnlock(uid id.UserID) error {
|
||||
for i, z := range []ZoneID{ZoneDragonsLair, ZoneAbyssPortal} {
|
||||
if _, err := db.Get().Exec(
|
||||
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, boss_defeated)
|
||||
VALUES (?, ?, ?, ?, 1)`,
|
||||
fmt.Sprintf("sim-unlock-%s-%d", uid, i), string(uid), string(z), ExpeditionStatusComplete,
|
||||
); err != nil {
|
||||
return fmt.Errorf("seed T5 clear %s: %w", z, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPostgameZone exports the T6 predicate so the sim binary (a separate
|
||||
// package) can decide whether to call SeedPostgameUnlock before a run.
|
||||
func IsPostgameZone(id ZoneID) bool { return isPostgameZone(id) }
|
||||
|
||||
// PrepareRealCharacter readies an already-persisted character (loaded from a
|
||||
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
|
||||
// nothing: the character keeps its real race/class/subclass/level, ability
|
||||
@@ -294,8 +318,16 @@ func applyClassBaselineStats(c *DnDCharacter) {
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10
|
||||
case ClassRogue, ClassRanger:
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8
|
||||
case ClassMage, ClassSorcerer:
|
||||
case ClassMage:
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 16, 12, 10
|
||||
case ClassSorcerer:
|
||||
// Sorcerer is a CHA caster (spellcastingMod → CHA), so its 16 goes in
|
||||
// CHA, not INT. Previously it shared the Mage's INT-heavy array, which
|
||||
// left the synthetic sorcerer casting at CHA mod 0 — every CHA-scaled
|
||||
// ability (cantrip, Innate Sorcery, spell DCs) ran crippled and sorc
|
||||
// trailed the field in every sweep. Prod players always placed 16 in
|
||||
// CHA; this makes the sim's sorcerer match a real one.
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 10, 12, 16
|
||||
case ClassCleric, ClassDruid:
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13
|
||||
case ClassBard, ClassWarlock:
|
||||
|
||||
@@ -98,6 +98,75 @@ func TestSeatParty_RefusedFollowerHaltsTheRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: a party could never seat a follower into a Tier 6 post-game
|
||||
// zone. expeditionCmdAccept ran the plain zoneOpenToLevel level gate first, and
|
||||
// T6 zones are excluded from zonesForLevel by design, so every unlocked member
|
||||
// was refused ("beyond you for now") before the post-game check could admit
|
||||
// them. The intended party-only endgame was unreachable. The fix routes T6
|
||||
// zones through postgameUnlocked instead of the level gate.
|
||||
func TestSeatParty_PostgameZoneSeatsUnlockedMembers(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
s := newPartySimRunner()
|
||||
|
||||
leader := id.UserID("@sim-pg-ok:example")
|
||||
member := id.UserID("@sim-pg-ok-m1:example")
|
||||
for _, u := range []id.UserID{leader, member} {
|
||||
if _, err := s.BuildCharacter(u, ClassFighter, postgameLevelFloor+2); err != nil {
|
||||
t.Fatalf("build %s: %v", u, err)
|
||||
}
|
||||
s.Euro.Credit(u, 50000, "test")
|
||||
if err := s.SeedPostgameUnlock(u); err != nil {
|
||||
t.Fatalf("seed unlock %s: %v", u, err)
|
||||
}
|
||||
}
|
||||
defer cleanupExpeditions(leader)
|
||||
|
||||
ctx := MessageContext{Sender: leader}
|
||||
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(ZoneOssuaryAscendant)+" heavy"); err != nil {
|
||||
t.Fatalf("start T6 expedition: %v", err)
|
||||
}
|
||||
if err := s.seatParty(leader, []id.UserID{member}); err != nil {
|
||||
t.Fatalf("seatParty refused an unlocked member into a T6 zone: %v", err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(leader)
|
||||
if exp == nil {
|
||||
t.Fatal("expedition vanished while seating")
|
||||
}
|
||||
if size, err := partySize(exp.ID); err != nil || size != 2 {
|
||||
t.Fatalf("roster = %d (%v), want 2 (leader + 1 unlocked member)", size, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The post-game gate must still refuse a member who has NOT beaten both T5
|
||||
// bosses, even in a party: no smuggling an ungated seat into a T6 zone. The
|
||||
// leader is unlocked (so the expedition starts); the member is not.
|
||||
func TestSeatParty_PostgameZoneRefusesUngatedMember(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
s := newPartySimRunner()
|
||||
|
||||
leader := id.UserID("@sim-pg-gate:example")
|
||||
member := id.UserID("@sim-pg-gate-m1:example")
|
||||
for _, u := range []id.UserID{leader, member} {
|
||||
if _, err := s.BuildCharacter(u, ClassFighter, postgameLevelFloor+2); err != nil {
|
||||
t.Fatalf("build %s: %v", u, err)
|
||||
}
|
||||
s.Euro.Credit(u, 50000, "test")
|
||||
}
|
||||
// Only the leader clears the T5 gate; the member stays ungated.
|
||||
if err := s.SeedPostgameUnlock(leader); err != nil {
|
||||
t.Fatalf("seed leader unlock: %v", err)
|
||||
}
|
||||
defer cleanupExpeditions(leader)
|
||||
|
||||
ctx := MessageContext{Sender: leader}
|
||||
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(ZoneOssuaryAscendant)+" heavy"); err != nil {
|
||||
t.Fatalf("start T6 expedition: %v", err)
|
||||
}
|
||||
if err := s.seatParty(leader, []id.UserID{member}); err == nil {
|
||||
t.Fatal("seatParty admitted an ungated member into a T6 zone")
|
||||
}
|
||||
}
|
||||
|
||||
// A misspelled -class / -party-classes token used to build a character at the
|
||||
// unknown-class fallback — 1 HP — and the run reported a perfectly normal
|
||||
// outcome for it. Nothing downstream treats an unknown class as an error, so
|
||||
|
||||
@@ -46,10 +46,11 @@ type MagicItem struct {
|
||||
}
|
||||
|
||||
// magicItemOverlay — hand-authored magic items that win on ID collision with
|
||||
// the generated SRD dump. Empty for now: the classifier output is the starting
|
||||
// point, and corrections (or wholly new items) land here rather than being
|
||||
// edited into the generated file.
|
||||
var magicItemOverlay []MagicItem
|
||||
// the generated SRD dump. Corrections (or wholly new items) land here rather
|
||||
// than being edited into the generated file. Today it carries the T6 signature
|
||||
// items (postgame_magic_items.go); this is a plain var reference, not an init()
|
||||
// append, so it resolves before buildMagicItemRegistry() reads it.
|
||||
var magicItemOverlay = postgameSignatureMagicItems
|
||||
|
||||
// magicItemRegistry is the merged lookup table: the generated SRD dump with the
|
||||
// hand-authored overlay layered on top.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -184,9 +185,10 @@ type magicItemEffect struct {
|
||||
}
|
||||
|
||||
// magicItemEffectOverlay — hand-authored per-item effects that win over the
|
||||
// codified formula. Empty for now; corrections land here rather than being
|
||||
// folded into the formula, mirroring magicItemOverlay in magic_items.go.
|
||||
var magicItemEffectOverlay = map[string]magicItemEffect{}
|
||||
// codified formula. Corrections land here rather than being folded into the
|
||||
// formula, mirroring magicItemOverlay in magic_items.go. Today it carries the
|
||||
// T6 signature items' bespoke combat numbers (postgame_magic_items.go).
|
||||
var magicItemEffectOverlay = postgameSignatureEffects
|
||||
|
||||
// rarityPowerScalar is the codified power axis: rarer item, bigger delta.
|
||||
func rarityPowerScalar(r DnDRarity) float64 {
|
||||
@@ -328,6 +330,42 @@ func countAttunedMagicItems(equipped map[DnDSlot]EquippedMagicItem) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// reconcileMagicAttunements bonds any worn attunement item that is sitting
|
||||
// inert while a bond slot is free. Bonding is strictly beneficial (there are no
|
||||
// cursed items), so the only legitimate reason for a worn attunement item to be
|
||||
// unbonded is the hard cap — the moment a slot frees, every straggler should
|
||||
// light up. Without this, an item equipped while at cap stays permanently inert:
|
||||
// the equip picker only lists inventory, so a slotted item can never be reached
|
||||
// to re-attempt bonding. Idempotent; returns the names of items it newly bonded.
|
||||
func reconcileMagicAttunements(userID id.UserID) ([]string, error) {
|
||||
equipped, err := loadEquippedMagicItems(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
used := countAttunedMagicItems(equipped)
|
||||
if used >= dndMagicItemAttuneLimit {
|
||||
return nil, nil
|
||||
}
|
||||
var bonded []string
|
||||
for _, ds := range dndSlotOrder { // deterministic order when slots are scarce
|
||||
if used >= dndMagicItemAttuneLimit {
|
||||
break
|
||||
}
|
||||
e, ok := equipped[ds]
|
||||
if !ok || e.Item.ID == "" || !e.Item.Attunement || e.Attuned {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE magic_item_equipped SET attuned = 1 WHERE user_id = ? AND slot = ?`,
|
||||
string(userID), string(ds)); err != nil {
|
||||
return bonded, err
|
||||
}
|
||||
bonded = append(bonded, e.Item.Name)
|
||||
used++
|
||||
}
|
||||
return bonded, nil
|
||||
}
|
||||
|
||||
// ── Combat hook ─────────────────────────────────────────────────────────────
|
||||
|
||||
// applyMagicItemEffects layers the player's equipped magic items onto their
|
||||
@@ -522,7 +560,140 @@ func magicItemEffectSummary(mi MagicItem) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// The equip mutation, message-free, shared by the DM resolver above and the web
|
||||
// equip queue (pete_equip.go). Splitting it out is deliberate: the ordering below
|
||||
// — evict occupant, then remove-from-inventory *before* equip, with a rollback if
|
||||
// equip fails — is the whole defence against item duplication, and a second copy
|
||||
// of it living in the web path is exactly where that defence would silently rot.
|
||||
// One implementation, two callers.
|
||||
|
||||
// errItemNotEquippable is a *permanent* refusal (not a magic item, or a slotless
|
||||
// curio) — the caller should reject the request, not retry it. Every other error
|
||||
// from applyMagicEquip is a transient DB fault the caller may retry.
|
||||
var errItemNotEquippable = errors.New("magic-item: not equippable")
|
||||
|
||||
// errSlotEmpty is applyMagicUnequip's permanent refusal: nothing is in that slot.
|
||||
var errSlotEmpty = errors.New("magic-item: slot empty")
|
||||
|
||||
// magicEquipOutcome is what an equip did, for the caller to narrate. BondsBefore
|
||||
// is the attunement count *after* any swap-eviction and *before* this item, so a
|
||||
// caller reporting "bonded (N/3)" adds one.
|
||||
type magicEquipOutcome struct {
|
||||
Effective MagicItem // the item as worn, tempering folded in
|
||||
SwappedBack string // name of the occupant sent back to inventory, or ""
|
||||
Bonded bool // this item took a bond just now
|
||||
AtCap bool // worn but inert: it wants a bond and all 3 are used
|
||||
BondsBefore int // bonds in use before this item was worn
|
||||
Healed []string // stragglers a freed slot let bond, post-equip
|
||||
}
|
||||
|
||||
// applyMagicEquip wears one inventory item, preserving the anti-duplication
|
||||
// ordering. It mutates the equipment tables and sends nothing.
|
||||
func applyMagicEquip(userID id.UserID, it AdvItem) (magicEquipOutcome, error) {
|
||||
mi, ok := magicItemFromAdvItem(it)
|
||||
if !ok || mi.Slot == "" {
|
||||
return magicEquipOutcome{}, errItemNotEquippable
|
||||
}
|
||||
equipped, err := loadEquippedMagicItems(userID)
|
||||
if err != nil {
|
||||
return magicEquipOutcome{}, err
|
||||
}
|
||||
|
||||
// Return whatever occupies that slot to inventory at full value, and drop it
|
||||
// from the local map so the bond count below reflects the post-swap state.
|
||||
var swappedBack string
|
||||
if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" {
|
||||
back := magicItemSellAt(prev.Item, prev.Temper)
|
||||
back.SkillSource = "magic_item:" + prev.Item.ID
|
||||
if err := addAdvInventoryItem(userID, back); err != nil {
|
||||
return magicEquipOutcome{}, err
|
||||
}
|
||||
swappedBack = prev.Item.Name
|
||||
delete(equipped, mi.Slot)
|
||||
}
|
||||
|
||||
bondsBefore := countAttunedMagicItems(equipped)
|
||||
bonded, atCap := false, false
|
||||
if mi.Attunement {
|
||||
if bondsBefore >= dndMagicItemAttuneLimit {
|
||||
atCap = true
|
||||
} else {
|
||||
bonded = true
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the inventory row FIRST, then equip; if equip fails, restore the row.
|
||||
// The reverse order left a transient failure with the item both worn and in the
|
||||
// pack — a free duplicate.
|
||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||
slog.Error("magic-item: failed to remove from inventory before equip",
|
||||
"user", userID, "item", mi.ID, "err", err)
|
||||
return magicEquipOutcome{}, err
|
||||
}
|
||||
if err := equipMagicItem(userID, mi.Slot, mi.ID, bonded, it.Temper); err != nil {
|
||||
restored := magicItemSellAt(mi, it.Temper)
|
||||
restored.Value = it.Value
|
||||
restored.SkillSource = "magic_item:" + mi.ID
|
||||
if rbErr := addAdvInventoryItem(userID, restored); rbErr != nil {
|
||||
slog.Error("magic-item: equip failed AND inventory rollback failed",
|
||||
"user", userID, "item", mi.ID, "equip_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return magicEquipOutcome{}, err
|
||||
}
|
||||
|
||||
// Swapping the occupant out may have freed a bond slot — light up any straggler.
|
||||
healed, _ := reconcileMagicAttunements(userID)
|
||||
return magicEquipOutcome{
|
||||
Effective: temperedItem(mi, it.Temper),
|
||||
SwappedBack: swappedBack,
|
||||
Bonded: bonded,
|
||||
AtCap: atCap,
|
||||
BondsBefore: bondsBefore,
|
||||
Healed: healed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// magicUnequipOutcome is what an unequip did, for the caller to narrate.
|
||||
type magicUnequipOutcome struct {
|
||||
Item MagicItem // the item taken off, as it was worn
|
||||
Healed []string // stragglers the freed bond slot let bond
|
||||
}
|
||||
|
||||
// applyMagicUnequip takes the item off a slot and returns it to inventory,
|
||||
// mirroring the equip ordering (destructive op first, restore on failure). Sends
|
||||
// nothing.
|
||||
func applyMagicUnequip(userID id.UserID, slot DnDSlot) (magicUnequipOutcome, error) {
|
||||
equipped, err := loadEquippedMagicItems(userID)
|
||||
if err != nil {
|
||||
return magicUnequipOutcome{}, err
|
||||
}
|
||||
e, ok := equipped[slot]
|
||||
if !ok || e.Item.ID == "" {
|
||||
return magicUnequipOutcome{}, errSlotEmpty
|
||||
}
|
||||
if err := unequipMagicItem(userID, slot); err != nil {
|
||||
return magicUnequipOutcome{}, err
|
||||
}
|
||||
back := magicItemSellAt(e.Item, e.Temper)
|
||||
back.SkillSource = "magic_item:" + e.Item.ID
|
||||
if err := addAdvInventoryItem(userID, back); err != nil {
|
||||
if rbErr := equipMagicItem(userID, slot, e.Item.ID, e.Attuned, e.Temper); rbErr != nil {
|
||||
slog.Error("magic-item: unequip failed AND re-equip rollback failed",
|
||||
"user", userID, "item", e.Item.ID, "inv_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return magicUnequipOutcome{}, err
|
||||
}
|
||||
healed, _ := reconcileMagicAttunements(userID)
|
||||
return magicUnequipOutcome{Item: e.Effective(), Healed: healed}, nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error {
|
||||
// Self-heal first: bond any worn item stranded inert while a slot is free
|
||||
// (e.g. equipped at cap, then a bond slot opened). This is the only path a
|
||||
// slotted-but-inert item can be reached from, since the picker below lists
|
||||
// inventory only.
|
||||
healed, _ := reconcileMagicAttunements(ctx.Sender)
|
||||
|
||||
items, err := loadAdvInventory(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to access your inventory.")
|
||||
@@ -538,13 +709,23 @@ func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error {
|
||||
}
|
||||
magic = append(magic, it)
|
||||
}
|
||||
healNote := ""
|
||||
if len(healed) > 0 {
|
||||
healNote = fmt.Sprintf("🔗 A bond slot freed up — **%s** bonded and is now active.\n\n",
|
||||
strings.Join(healed, "**, **"))
|
||||
}
|
||||
|
||||
if len(magic) == 0 {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"No curios to equip yet — they drop from zones or Luigi's 🔮 shelf.")
|
||||
msg := "No curios to equip yet — they drop from zones or Luigi's 🔮 shelf."
|
||||
if healNote != "" {
|
||||
msg = strings.TrimSpace(healNote)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
|
||||
equipped, _ := loadEquippedMagicItems(ctx.Sender)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(healNote)
|
||||
sb.WriteString("🔮 **Equippable magic items:**\n\n")
|
||||
for i, it := range magic {
|
||||
base, _ := magicItemFromAdvItem(it)
|
||||
@@ -586,78 +767,108 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
|
||||
}
|
||||
|
||||
it := data.Items[idx]
|
||||
mi, ok := magicItemFromAdvItem(it)
|
||||
if !ok || mi.Slot == "" {
|
||||
out, err := applyMagicEquip(ctx.Sender, it)
|
||||
if errors.Is(err, errItemNotEquippable) {
|
||||
return p.SendDM(ctx.Sender, "That item can't be equipped anymore.")
|
||||
}
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.",
|
||||
out.Effective.Name, out.Effective.Slot, magicItemEffectSummary(out.Effective)))
|
||||
if out.SwappedBack != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", out.SwappedBack))
|
||||
}
|
||||
switch {
|
||||
case out.Bonded:
|
||||
sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).",
|
||||
out.BondsBefore+1, dndMagicItemAttuneLimit))
|
||||
case out.AtCap:
|
||||
sb.WriteString(fmt.Sprintf("\n⚠️ All %d bond slots are full — it's worn but **inert** until you free one (`!adventure unequip-magic` to take a bonded item off; this one bonds automatically once a slot opens).",
|
||||
dndMagicItemAttuneLimit))
|
||||
}
|
||||
if len(out.Healed) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\n🔗 A freed bond slot also activated **%s**.",
|
||||
strings.Join(out.Healed, "**, **")))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
// ── Unequip command (`!adventure unequip-magic`) ─────────────────────────────
|
||||
|
||||
// advPendingMagicUnequip is the pending-interaction payload for the numbered
|
||||
// unequip picker. It lists slots (not inventory rows) because the item lives in
|
||||
// magic_item_equipped, not adventure_inventory.
|
||||
type advPendingMagicUnequip struct {
|
||||
Slots []DnDSlot
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleUnequipMagicCmd(ctx MessageContext) error {
|
||||
equipped, err := loadEquippedMagicItems(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.")
|
||||
}
|
||||
|
||||
// Return whatever currently occupies that slot to inventory at full
|
||||
// value — swapping a curio shouldn't tax it. Evict from the local map
|
||||
// too, so the attunement count below reflects the post-swap state and
|
||||
// can re-open a slot the prior occupant was holding.
|
||||
var swappedBackName string
|
||||
if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" {
|
||||
back := magicItemSellAt(prev.Item, prev.Temper)
|
||||
back.SkillSource = "magic_item:" + prev.Item.ID
|
||||
if err := addAdvInventoryItem(ctx.Sender, back); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to return your currently-equipped item to inventory.")
|
||||
}
|
||||
swappedBackName = prev.Item.Name
|
||||
delete(equipped, mi.Slot)
|
||||
}
|
||||
|
||||
// Auto-attune when the item needs it and an attunement slot is free.
|
||||
// Otherwise it equips inert until the player frees a slot.
|
||||
attune := false
|
||||
atCap := false
|
||||
if mi.Attunement {
|
||||
if countAttunedMagicItems(equipped) >= dndMagicItemAttuneLimit {
|
||||
atCap = true
|
||||
} else {
|
||||
attune = true
|
||||
}
|
||||
}
|
||||
// Remove the inventory row FIRST, then equip. If equip fails after the
|
||||
// remove succeeded, restore inventory. Doing it in the other order
|
||||
// meant a transient DB error on remove left the item both equipped
|
||||
// *and* still in inventory — a free duplication.
|
||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||
slog.Error("magic-item: failed to remove from inventory before equip",
|
||||
"user", ctx.Sender, "item", mi.ID, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||
}
|
||||
if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune, it.Temper); err != nil {
|
||||
// Roll back: try to put the item back in inventory so the player
|
||||
// doesn't lose it. Best-effort; log if the rollback also fails.
|
||||
restored := magicItemSellAt(mi, it.Temper)
|
||||
restored.Value = it.Value
|
||||
restored.SkillSource = "magic_item:" + mi.ID
|
||||
if rbErr := addAdvInventoryItem(ctx.Sender, restored); rbErr != nil {
|
||||
slog.Error("magic-item: equip failed AND inventory rollback failed",
|
||||
"user", ctx.Sender, "item", mi.ID, "equip_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||
}
|
||||
|
||||
eqMI := temperedItem(mi, it.Temper)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.",
|
||||
eqMI.Name, eqMI.Slot, magicItemEffectSummary(eqMI)))
|
||||
if swappedBackName != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", swappedBackName))
|
||||
sb.WriteString("🔮 **Worn magic items** — reply with a number to take one off (it returns to your inventory), or \"cancel\".\n\n")
|
||||
var slots []DnDSlot
|
||||
for _, ds := range dndSlotOrder { // stable numbering across repeated calls
|
||||
e, ok := equipped[ds]
|
||||
if !ok || e.Item.ID == "" {
|
||||
continue
|
||||
}
|
||||
slots = append(slots, ds)
|
||||
mi := e.Effective()
|
||||
status := ""
|
||||
if mi.Attunement {
|
||||
if e.Attuned {
|
||||
status = " — bonded"
|
||||
} else {
|
||||
status = " — _(inert)_"
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. **%s** _(%s)_ → %s slot%s\n",
|
||||
len(slots), mi.Name, mi.Rarity, ds, status))
|
||||
}
|
||||
switch {
|
||||
case mi.Attunement && attune:
|
||||
sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).",
|
||||
countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit))
|
||||
case mi.Attunement && atCap:
|
||||
sb.WriteString(fmt.Sprintf("\n⚠️ All %d bond slots are full — it's worn but **inert** until you free one (`!adventure equip-magic` to swap).",
|
||||
dndMagicItemAttuneLimit))
|
||||
if len(slots) == 0 {
|
||||
return p.SendDM(ctx.Sender, "You aren't wearing any magic items. `!adventure equip-magic` to put one on.")
|
||||
}
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "magic_unequip",
|
||||
Data: &advPendingMagicUnequip{Slots: slots},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveMagicUnequipReply(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
data := interaction.Data.(*advPendingMagicUnequip)
|
||||
reply := strings.TrimSpace(ctx.Body)
|
||||
if strings.EqualFold(reply, "cancel") {
|
||||
return p.SendDM(ctx.Sender, "Unequip cancelled.")
|
||||
}
|
||||
idx, ok := parseMenuIndex(reply, len(data.Slots))
|
||||
if !ok {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
|
||||
}
|
||||
|
||||
slot := data.Slots[idx]
|
||||
out, err := applyMagicUnequip(ctx.Sender, slot)
|
||||
if errors.Is(err, errSlotEmpty) {
|
||||
return p.SendDM(ctx.Sender, "That slot is already empty.")
|
||||
}
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to take that item off.")
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", out.Item.Name, slot)
|
||||
// Freeing a bonded slot may let a worn-but-inert item finally bond.
|
||||
if len(out.Healed) > 0 {
|
||||
msg += fmt.Sprintf("\n🔗 That freed a bond slot — **%s** is now active.",
|
||||
strings.Join(out.Healed, "**, **"))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
|
||||
@@ -252,6 +252,152 @@ func TestSwapBackReturnsFullValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileBondsStrandedItem reproduces the player report: an attunement
|
||||
// item equipped while at the bond cap sits inert, then a bond slot frees up.
|
||||
// The item is worn (not in inventory) so the equip picker can never reach it —
|
||||
// reconcile must be what lights it up.
|
||||
func TestReconcileBondsStrandedItem(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
user := id.UserID("@stranded:test.invalid")
|
||||
|
||||
// Gather attunement items in distinct slots — need cap+1 of them.
|
||||
seen := map[DnDSlot]bool{}
|
||||
var att []MagicItem
|
||||
for _, ds := range dndSlotOrder {
|
||||
for _, mi := range magicItemRegistry {
|
||||
if mi.Attunement && mi.Slot == ds && !seen[ds] {
|
||||
att = append(att, mi)
|
||||
seen[ds] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(att) < dndMagicItemAttuneLimit+1 {
|
||||
t.Skipf("registry has only %d attunement slots, need %d", len(att), dndMagicItemAttuneLimit+1)
|
||||
}
|
||||
|
||||
// Bond the first `limit` items, then wear one more inert (attuned=false).
|
||||
for i := 0; i < dndMagicItemAttuneLimit; i++ {
|
||||
if err := equipMagicItem(user, att[i].Slot, att[i].ID, true, 0); err != nil {
|
||||
t.Fatalf("bond seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
stranded := att[dndMagicItemAttuneLimit]
|
||||
if err := equipMagicItem(user, stranded.Slot, stranded.ID, false, 0); err != nil {
|
||||
t.Fatalf("equip stranded: %v", err)
|
||||
}
|
||||
|
||||
// At cap, reconcile must be a no-op — the stranded item stays inert.
|
||||
if healed, err := reconcileMagicAttunements(user); err != nil || len(healed) != 0 {
|
||||
t.Fatalf("reconcile at cap = %v (err %v), want no change", healed, err)
|
||||
}
|
||||
|
||||
// Free a slot (the player swaps out a bonded item), then reconcile.
|
||||
if err := unequipMagicItem(user, att[0].Slot); err != nil {
|
||||
t.Fatalf("free slot: %v", err)
|
||||
}
|
||||
healed, err := reconcileMagicAttunements(user)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile after free: %v", err)
|
||||
}
|
||||
if len(healed) != 1 || healed[0] != stranded.Name {
|
||||
t.Fatalf("reconcile healed %v, want [%s]", healed, stranded.Name)
|
||||
}
|
||||
|
||||
equipped, _ := loadEquippedMagicItems(user)
|
||||
if !equipped[stranded.Slot].Attuned {
|
||||
t.Errorf("stranded item still inert after reconcile")
|
||||
}
|
||||
if got := countAttunedMagicItems(equipped); got != dndMagicItemAttuneLimit {
|
||||
t.Errorf("bonded count = %d, want %d", got, dndMagicItemAttuneLimit)
|
||||
}
|
||||
|
||||
// Idempotent: a second pass changes nothing.
|
||||
if healed, _ := reconcileMagicAttunements(user); len(healed) != 0 {
|
||||
t.Errorf("second reconcile healed %v, want none", healed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnequipMagicReturnsToInventoryAndHeals drives the unequip resolver end to
|
||||
// end: taking a bonded item off must return it to inventory AND free its bond
|
||||
// slot so a worn-but-inert item lights up.
|
||||
func TestUnequipMagicReturnsToInventoryAndHeals(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
user := id.UserID("@unequip:test.invalid")
|
||||
|
||||
seen := map[DnDSlot]bool{}
|
||||
var att []MagicItem
|
||||
for _, ds := range dndSlotOrder {
|
||||
for _, mi := range magicItemRegistry {
|
||||
if mi.Attunement && mi.Slot == ds && !seen[ds] {
|
||||
att = append(att, mi)
|
||||
seen[ds] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(att) < dndMagicItemAttuneLimit+1 {
|
||||
t.Skipf("registry has only %d attunement slots, need %d", len(att), dndMagicItemAttuneLimit+1)
|
||||
}
|
||||
|
||||
// Bond the cap, then wear one more inert.
|
||||
for i := 0; i < dndMagicItemAttuneLimit; i++ {
|
||||
if err := equipMagicItem(user, att[i].Slot, att[i].ID, true, 0); err != nil {
|
||||
t.Fatalf("bond seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
stranded := att[dndMagicItemAttuneLimit]
|
||||
if err := equipMagicItem(user, stranded.Slot, stranded.ID, false, 0); err != nil {
|
||||
t.Fatalf("equip stranded: %v", err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{} // nil Sink/Client → SendDM is a no-op
|
||||
interaction := &advPendingInteraction{
|
||||
Type: "magic_unequip",
|
||||
Data: &advPendingMagicUnequip{Slots: []DnDSlot{att[0].Slot}},
|
||||
}
|
||||
if err := p.resolveMagicUnequipReply(MessageContext{Sender: user, Body: "1"}, interaction); err != nil {
|
||||
t.Fatalf("resolve unequip: %v", err)
|
||||
}
|
||||
|
||||
// The unequipped item is back in inventory as a curio.
|
||||
inv, err := loadAdvInventory(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, it := range inv {
|
||||
if it.SkillSource == "magic_item:"+att[0].ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("unequipped item %q not returned to inventory", att[0].ID)
|
||||
}
|
||||
|
||||
// Its slot is empty, and the freed bond slot bonded the stranded item.
|
||||
equipped, _ := loadEquippedMagicItems(user)
|
||||
if _, still := equipped[att[0].Slot]; still {
|
||||
t.Errorf("slot %s still occupied after unequip", att[0].Slot)
|
||||
}
|
||||
if !equipped[stranded.Slot].Attuned {
|
||||
t.Errorf("stranded item did not bond after a slot freed")
|
||||
}
|
||||
if got := countAttunedMagicItems(equipped); got != dndMagicItemAttuneLimit {
|
||||
t.Errorf("bonded count = %d, want %d", got, dndMagicItemAttuneLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquippedMagicItemRoundTrip exercises the DB persistence layer: equip,
|
||||
// load, attunement counting, unequip.
|
||||
func TestEquippedMagicItemRoundTrip(t *testing.T) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,12 @@ func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) {
|
||||
}
|
||||
}
|
||||
f.Actors = actors
|
||||
// Author the prose in Pete's voice from the FINAL fact, so the names in the
|
||||
// dispatch match the Actors allow-list Pete guards against. Best-effort: an
|
||||
// empty pair (LLM off or authoring failed) just means Pete templates the
|
||||
// fact. Synchronous, like the holdem tip rewrite — news facts are infrequent
|
||||
// and the call is tightly bounded (dispatchLLMTimeout).
|
||||
f.Headline, f.Lede = authorDispatch(f)
|
||||
peteclient.Emit(f)
|
||||
}
|
||||
|
||||
@@ -336,3 +342,64 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
// treasureRarityWord maps a treasure def's tier to the rarity adjective Pete
|
||||
// weaves into a find's dispatch. Story-grade treasures are typically tier 5, so
|
||||
// "legendary" is the common case; the lower tiers are here for completeness.
|
||||
func treasureRarityWord(tier int) string {
|
||||
switch {
|
||||
case tier >= 5:
|
||||
return "legendary"
|
||||
case tier == 4:
|
||||
return "epic"
|
||||
case tier == 3:
|
||||
return "rare"
|
||||
case tier == 2:
|
||||
return "uncommon"
|
||||
default:
|
||||
return "common"
|
||||
}
|
||||
}
|
||||
|
||||
// emitTreasureFound files a story-grade treasure find. Only treasures carrying a
|
||||
// RoomAnnounce string reach here — the same gate that earns them a public moment
|
||||
// — so a copper-piece pickup never becomes news. The realm's first finder of a
|
||||
// given treasure is a PRIORITY hoard; a later finder of the same item is a
|
||||
// BULLETIN, the first/repeat split zone_first already uses. Character name only;
|
||||
// no-op unless the seam is enabled.
|
||||
//
|
||||
// treasure_found is an event_type Pete's ingest must already know: an unknown
|
||||
// type 400s, retries to the cap, then parks the bulletin forever. Deploy Pete
|
||||
// first.
|
||||
func emitTreasureFound(userID id.UserID, def *AdvTreasureDef, loc *AdvLocation) {
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
if def == nil || loc == nil {
|
||||
return
|
||||
}
|
||||
// Claim the realm-first BEFORE the name guard, so an unnamed straggler's
|
||||
// genuine first find still seeds the ledger and the next finder isn't
|
||||
// mis-billed as the first-ever. Mirrors emitZoneClearNews.
|
||||
tier := "bulletin"
|
||||
if claimRealmFirst("treasure", def.Key) {
|
||||
tier = "priority"
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("%s:%d", def.Key, ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("treasure_found:%s:%s:%d", eventToken(userID, disc), def.Key, ts),
|
||||
EventType: "treasure_found",
|
||||
Tier: tier,
|
||||
Subject: name,
|
||||
Zone: loc.Name,
|
||||
Level: charLevel(userID),
|
||||
Stakes: def.Name,
|
||||
Outcome: treasureRarityWord(def.Tier),
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
169
internal/plugin/pete_compare_test.go
Normal file
169
internal/plugin/pete_compare_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
)
|
||||
|
||||
// weapon/ring/wondrous helpers build a MagicItem the codified effect formula
|
||||
// (magicItemEffectFor) can score without touching the DB.
|
||||
func mkItem(kind MagicItemKind, rarity DnDRarity, slot DnDSlot) MagicItem {
|
||||
return MagicItem{ID: string(kind) + "_" + string(rarity), Kind: kind, Rarity: rarity, Slot: slot}
|
||||
}
|
||||
|
||||
// TestMagicItemDeltasDirection pins the "which way is better" call for each stat,
|
||||
// including DamageReductMult where LOWER is the improvement.
|
||||
func TestMagicItemDeltasDirection(t *testing.T) {
|
||||
neutral := magicItemEffect{DamageReductMult: 1.0}
|
||||
|
||||
t.Run("more damage is better", func(t *testing.T) {
|
||||
d := magicItemDeltas(magicItemEffect{DamageBonus: 0.15, DamageReductMult: 1.0}, magicItemEffect{DamageBonus: 0.10, DamageReductMult: 1.0})
|
||||
if len(d) != 1 || d[0].Label != "damage" || !d[0].Better || d[0].Text != "+5% damage" {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("less damage taken is better", func(t *testing.T) {
|
||||
// cand mult 0.90 (blocks 10%) vs worn neutral 1.0 (blocks nothing).
|
||||
d := magicItemDeltas(magicItemEffect{DamageReductMult: 0.90}, neutral)
|
||||
if len(d) != 1 || d[0].Label != "defense" || !d[0].Better || d[0].Text != "-10% damage taken" {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("weaker armor reads as worse", func(t *testing.T) {
|
||||
// cand blocks less than worn: mult rises, damage taken goes up.
|
||||
d := magicItemDeltas(magicItemEffect{DamageReductMult: 0.96}, magicItemEffect{DamageReductMult: 0.90})
|
||||
if len(d) != 1 || d[0].Better || d[0].Text != "+6% damage taken" {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hp and opening damage", func(t *testing.T) {
|
||||
d := magicItemDeltas(
|
||||
magicItemEffect{DamageReductMult: 1.0, MaxHP: 10, FlatDmgStart: 3},
|
||||
magicItemEffect{DamageReductMult: 1.0, MaxHP: 6, FlatDmgStart: 5},
|
||||
)
|
||||
byLabel := map[string]itemDelta{}
|
||||
for _, x := range d {
|
||||
byLabel[x.Label] = itemDelta{x.Better, x.Text}
|
||||
}
|
||||
if v := byLabel["hp"]; v.text != "+4 HP" || !v.better {
|
||||
t.Errorf("hp: %+v", v)
|
||||
}
|
||||
if v := byLabel["opening"]; v.text != "-2 opening damage" || v.better {
|
||||
t.Errorf("opening: %+v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sub-percent change is dropped", func(t *testing.T) {
|
||||
// 0.004 fraction = 0.4% → rounds to 0% → not a visible delta.
|
||||
if d := magicItemDeltas(
|
||||
magicItemEffect{DamageBonus: 0.104, DamageReductMult: 1.0},
|
||||
magicItemEffect{DamageBonus: 0.100, DamageReductMult: 1.0},
|
||||
); len(d) != 0 {
|
||||
t.Fatalf("expected no visible delta, got %+v", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type itemDelta struct {
|
||||
better bool
|
||||
text string
|
||||
}
|
||||
|
||||
// TestCompareVerdict pins strict-dominance classification and the overrides.
|
||||
func TestCompareVerdict(t *testing.T) {
|
||||
gain := []peteclient.ItemDelta{{Better: true}}
|
||||
loss := []peteclient.ItemDelta{{Better: false}}
|
||||
mixed := []peteclient.ItemDelta{{Better: true}, {Better: false}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deltas []peteclient.ItemDelta
|
||||
empty, inrt bool
|
||||
want string
|
||||
}{
|
||||
{"all gains", gain, false, false, "upgrade"},
|
||||
{"all losses", loss, false, false, "downgrade"},
|
||||
{"mixed", mixed, false, false, "sidegrade"},
|
||||
{"no change", nil, false, false, "same"},
|
||||
{"empty slot", gain, true, false, "new"},
|
||||
{"inert overrides upgrade", gain, false, true, "inert"},
|
||||
{"inert overrides new", gain, true, true, "inert"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := compareVerdict(c.deltas, c.empty, c.inrt); got != c.want {
|
||||
t.Errorf("compareVerdict(%s) = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMagicItemCompareIntegration drives the whole builder against equipped maps.
|
||||
func TestMagicItemCompareIntegration(t *testing.T) {
|
||||
rareWpn := mkItem(MagicItemWeapon, RarityRare, DnDSlotMainHand) // +15% damage
|
||||
uncWpn := mkItem(MagicItemWeapon, RarityUncommon, DnDSlotMainHand) // +10% damage
|
||||
|
||||
t.Run("upgrade over a weaker worn weapon", func(t *testing.T) {
|
||||
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: uncWpn}}
|
||||
c := magicItemCompare(rareWpn, 0, eq)
|
||||
if c.Verdict != "upgrade" || c.VsName != uncWpn.Name || c.VsSlot != "main_hand" {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("downgrade under a stronger worn weapon", func(t *testing.T) {
|
||||
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: rareWpn}}
|
||||
if c := magicItemCompare(uncWpn, 0, eq); c.Verdict != "downgrade" {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same as an identical worn weapon", func(t *testing.T) {
|
||||
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: rareWpn}}
|
||||
c := magicItemCompare(rareWpn, 0, eq)
|
||||
if c.Verdict != "same" || len(c.Deltas) != 0 {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new into an empty slot names no worn item", func(t *testing.T) {
|
||||
c := magicItemCompare(rareWpn, 0, map[DnDSlot]EquippedMagicItem{})
|
||||
if c.Verdict != "new" || c.VsName != "" || len(c.Deltas) == 0 {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attunement item with no free bond is inert", func(t *testing.T) {
|
||||
ring := mkItem(MagicItemRing, RarityRare, DnDSlotRing1)
|
||||
ring.Attunement = true
|
||||
// Three bonds spent elsewhere; the ring slot is empty. Wearing it does nothing.
|
||||
eq := map[DnDSlot]EquippedMagicItem{
|
||||
DnDSlotChest: {Slot: DnDSlotChest, Item: mkItem(MagicItemArmor, RarityRare, DnDSlotChest), Attuned: true},
|
||||
DnDSlotAmulet: {Slot: DnDSlotAmulet, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotAmulet), Attuned: true},
|
||||
DnDSlotCloak: {Slot: DnDSlotCloak, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotCloak), Attuned: true},
|
||||
}
|
||||
if c := magicItemCompare(ring, 0, eq); c.Verdict != "inert" {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("evicting the worn occupant frees its bond, so not inert", func(t *testing.T) {
|
||||
ring := mkItem(MagicItemRing, RarityRare, DnDSlotRing1)
|
||||
ring.Attunement = true
|
||||
wornRing := mkItem(MagicItemRing, RarityUncommon, DnDSlotRing1)
|
||||
wornRing.Attunement = true
|
||||
// Three bonds spent — but one of them is the ring the candidate would replace.
|
||||
eq := map[DnDSlot]EquippedMagicItem{
|
||||
DnDSlotRing1: {Slot: DnDSlotRing1, Item: wornRing, Attuned: true},
|
||||
DnDSlotChest: {Slot: DnDSlotChest, Item: mkItem(MagicItemArmor, RarityRare, DnDSlotChest), Attuned: true},
|
||||
DnDSlotAmulet: {Slot: DnDSlotAmulet, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotAmulet), Attuned: true},
|
||||
}
|
||||
if c := magicItemCompare(ring, 0, eq); c.Verdict == "inert" {
|
||||
t.Fatalf("bond freed by eviction, should not be inert: %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
228
internal/plugin/pete_detail_test.go
Normal file
228
internal/plugin/pete_detail_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The adventurer detail-page push has two halves, matched to two sensitivities:
|
||||
// the public sheet (stats + gear) rides the roster snapshot keyed by the
|
||||
// anonymous token, and the private self-view (inventory/vault/house/pets) rides
|
||||
// its own push keyed by localpart. These tests pin both halves at the gogobee
|
||||
// end — that the public detail carries no handle, and that the private detail is
|
||||
// keyed so Pete can only ever hand it back to its owner.
|
||||
|
||||
// seedDetailPlayer builds a real, playable adventurer: player_meta + tier-0
|
||||
// equipment (via createAdvCharacter) plus a confirmed combat sheet. Real rows on
|
||||
// purpose — the same modernc scan hazard the roster snapshot dances around.
|
||||
func seedDetailPlayer(t *testing.T, uid id.UserID, name string, level int) {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, name); err != nil {
|
||||
t.Fatalf("createAdvCharacter(%s): %v", uid, err)
|
||||
}
|
||||
if err := SaveDnDCharacter(&DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||||
STR: 16, DEX: 14, CON: 15, INT: 10, WIS: 12, CHA: 8,
|
||||
HPMax: 42, HPCurrent: 30, TempHP: 5, ArmorClass: 17,
|
||||
PendingSetup: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter(%s): %v", uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterDetailPublicSheet: buildRosterSnapshot hangs the public sheet on
|
||||
// each board entry — HP/AC/abilities/mods and equipped gear — and nothing that
|
||||
// could name the player. This is the click-through page's whole payload.
|
||||
func TestRosterDetailPublicSheet(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@josie:test")
|
||||
seedDetailPlayer(t, uid, "Josie", 5)
|
||||
|
||||
snap, err := buildRosterSnapshot(time.Now().UTC(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||
}
|
||||
if len(snap.Adventurers) != 1 {
|
||||
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
|
||||
}
|
||||
e := snap.Adventurers[0]
|
||||
if e.Detail == nil {
|
||||
t.Fatal("board entry carries no detail sheet — the detail page would fall back to the bare row")
|
||||
}
|
||||
d := e.Detail
|
||||
if d.HPMax != 42 || d.HPCurrent != 30 || d.TempHP != 5 || d.ArmorClass != 17 {
|
||||
t.Errorf("detail sheet = %+v, want HP 30/42 (+5 temp), AC 17", d)
|
||||
}
|
||||
if d.Abilities != [6]int{16, 14, 15, 10, 12, 8} {
|
||||
t.Errorf("abilities = %v, want STR..CHA 16,14,15,10,12,8", d.Abilities)
|
||||
}
|
||||
// CON 15 → +2; a mismatched mods slice would misprint the whole sheet.
|
||||
if d.Modifiers[2] != 2 {
|
||||
t.Errorf("CON modifier = %d, want +2", d.Modifiers[2])
|
||||
}
|
||||
// createAdvCharacter seeds tier-0 gear in every slot, so the panel is full.
|
||||
if len(d.Gear) != len(allSlots) {
|
||||
t.Errorf("gear panel has %d pieces, want %d (one per slot)", len(d.Gear), len(allSlots))
|
||||
}
|
||||
for _, g := range d.Gear {
|
||||
if g.Slot == "" || g.Name == "" {
|
||||
t.Errorf("gear piece is unlabelled: %+v", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterDetailExpeditionContext: a player who is out on a run gets the live
|
||||
// expedition fields layered onto their sheet — supplies, threat, and the room
|
||||
// counter — so the detail page shows where they are, not just who they are.
|
||||
func TestRosterDetailExpeditionContext(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@onrun:test")
|
||||
exp := seedMischiefTarget(t, uid, 5, 60) // createAdvCharacter + sheet + live run
|
||||
_ = exp
|
||||
|
||||
snap, err := buildRosterSnapshot(time.Now().UTC(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||
}
|
||||
if len(snap.Adventurers) != 1 {
|
||||
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
|
||||
}
|
||||
e := snap.Adventurers[0]
|
||||
if e.Status != "expedition" {
|
||||
t.Fatalf("status = %q, want expedition", e.Status)
|
||||
}
|
||||
if e.Detail == nil {
|
||||
t.Fatal("on-run entry carries no detail")
|
||||
}
|
||||
if e.Detail.Room == "" {
|
||||
t.Error("expedition detail has no room counter — the run context didn't layer on")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailSnapshotKeyedByLocalpart is the ownership contract at the source.
|
||||
// The private set is keyed by localpart and carries the player's current board
|
||||
// token, so Pete can answer "is this signed-in viewer the owner of this page?"
|
||||
// by a join — never by reversing the one-way token. And it carries the actual
|
||||
// private goods: inventory, vault, house, pets.
|
||||
func TestDetailSnapshotKeyedByLocalpart(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@quack:test")
|
||||
seedDetailPlayer(t, uid, "Quack", 7)
|
||||
|
||||
// Backpack + vault: two distinct stashes that must not blur together.
|
||||
if err := addAdvInventoryItem(uid, AdvItem{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addAdvInventoryItem(uid, AdvItem{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := vaultStoreItem(uid, "crown"); got == "" {
|
||||
t.Fatal("failed to vault the crown")
|
||||
}
|
||||
|
||||
// House + a pet, through the canonical save path.
|
||||
adv, err := loadAdvCharacter(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("loadAdvCharacter: %v", err)
|
||||
}
|
||||
adv.HouseTier = 2
|
||||
adv.HouseLoanBalance = 1500
|
||||
adv.PetType = "cat"
|
||||
adv.PetName = "Mittens"
|
||||
adv.PetLevel = 3
|
||||
if err := saveAdvCharacter(adv); err != nil {
|
||||
t.Fatalf("saveAdvCharacter: %v", err)
|
||||
}
|
||||
|
||||
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||
}
|
||||
if len(snap.Players) != 1 {
|
||||
t.Fatalf("detail set has %d players, want 1", len(snap.Players))
|
||||
}
|
||||
pd := snap.Players[0]
|
||||
if pd.Localpart != "quack" {
|
||||
t.Errorf("localpart = %q, want the lowercase mxid localpart 'quack'", pd.Localpart)
|
||||
}
|
||||
if pd.Token != eventToken(uid, "roster") {
|
||||
t.Error("detail token is not the board token — the ownership join on Pete would never match the page")
|
||||
}
|
||||
// Inventory holds the ore (crown was vaulted out of it); vault holds the crown.
|
||||
if len(pd.Inventory) != 1 || pd.Inventory[0].Name != "Iron Ore" {
|
||||
t.Errorf("inventory = %+v, want just the ore", pd.Inventory)
|
||||
}
|
||||
if len(pd.Vault) != 1 || pd.Vault[0].Name != "Jeweled Crown" {
|
||||
t.Errorf("vault = %+v, want just the crown", pd.Vault)
|
||||
}
|
||||
if pd.House.Tier != 2 || pd.House.LoanBalance != 1500 {
|
||||
t.Errorf("house = %+v, want tier 2 / loan 1500", pd.House)
|
||||
}
|
||||
if len(pd.Pets) != 1 || pd.Pets[0].Name != "Mittens" || pd.Pets[0].Level != 3 {
|
||||
t.Errorf("pets = %+v, want the cat Mittens L3", pd.Pets)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailSnapshotIgnoresOptOut: the news opt-out governs the *public* board,
|
||||
// not the private self-view. A player who opted out must still receive their own
|
||||
// sheet — Pete only ever serves this back to them — so buildDetailSnapshot must
|
||||
// NOT filter on it, unlike buildRosterSnapshot.
|
||||
func TestDetailSnapshotIgnoresOptOut(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
shown := id.UserID("@shown:test")
|
||||
hidden := id.UserID("@hidden:test")
|
||||
seedDetailPlayer(t, shown, "Shown", 4)
|
||||
seedDetailPlayer(t, hidden, "Hidden", 4)
|
||||
setNewsOptout(hidden, true)
|
||||
|
||||
// The public board drops the opted-out player...
|
||||
roster, err := buildRosterSnapshot(time.Now().UTC(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||
}
|
||||
if len(roster.Adventurers) != 1 || roster.Adventurers[0].Name != "Shown" {
|
||||
t.Fatalf("public board = %d entries, want just Shown", len(roster.Adventurers))
|
||||
}
|
||||
|
||||
// ...but the private detail set keeps them both.
|
||||
detail, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||
}
|
||||
if len(detail.Players) != 2 {
|
||||
t.Fatalf("private detail set = %d players, want 2 — opt-out must not deny a player their own self-view", len(detail.Players))
|
||||
}
|
||||
byLP := map[string]bool{}
|
||||
for _, p := range detail.Players {
|
||||
byLP[p.Localpart] = true
|
||||
}
|
||||
if !byLP["hidden"] {
|
||||
t.Error("opted-out player is missing from the private self-view set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailSnapshotSkipsDeadPlayers: the set is drawn from alive players only.
|
||||
// A dead character has no live board page to own, and pushing their stale
|
||||
// inventory would leave it standing on Pete after they're gone.
|
||||
func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
alive := id.UserID("@alive:test")
|
||||
dead := id.UserID("@dead:test")
|
||||
seedDetailPlayer(t, alive, "Alive", 4)
|
||||
seedDetailPlayer(t, dead, "Dead", 4)
|
||||
if _, err := db.Get().Exec(`UPDATE player_meta SET alive = 0 WHERE user_id = ?`, string(dead)); err != nil {
|
||||
t.Fatalf("kill player: %v", err)
|
||||
}
|
||||
|
||||
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||
}
|
||||
if len(snap.Players) != 1 || snap.Players[0].Localpart != "alive" {
|
||||
t.Fatalf("detail set = %+v, want just the living player", snap.Players)
|
||||
}
|
||||
}
|
||||
201
internal/plugin/pete_dispatch_voice.go
Normal file
201
internal/plugin/pete_dispatch_voice.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
)
|
||||
|
||||
// LLM-authored adventure dispatches. gogobee owns the raw model compute; this is
|
||||
// where a structured fact becomes warm-reporter prose for Pete to publish. Pete
|
||||
// is still the editor and the safety boundary: it runs its own prose-guard over
|
||||
// whatever we send and falls back to its templates on anything it does not like,
|
||||
// so authoring here is best-effort by design — every failure path returns an
|
||||
// empty pair and Pete templates the fact.
|
||||
//
|
||||
// The voice must live somewhere, and with no route for Pete to call back into
|
||||
// this box (see roster.go in the Pete repo) it lives in the prompt below. Keep
|
||||
// it faithful to pete_adventure_news_voice.md; Pete's persona, not gogobee's.
|
||||
|
||||
// dispatchLLMTimeout bounds the authoring call. emitFact runs on game-event
|
||||
// chokepoints (a party wipe fires one per member), so this is deliberately far
|
||||
// tighter than the interactive 120s tip budget: if the model cannot turn a
|
||||
// handful of facts into two sentences this fast, it is effectively down, and a
|
||||
// template dispatch now beats a voiced one late.
|
||||
const dispatchLLMTimeout = 15 * time.Second
|
||||
|
||||
// Length ceilings, mirrored from Pete's proseGuard so we never ship prose Pete
|
||||
// will reject for length alone. Byte counts, matching Pete's len() check.
|
||||
const (
|
||||
maxDispatchHeadline = 200
|
||||
maxDispatchLede = 800
|
||||
)
|
||||
|
||||
var dispatchHTTP = &http.Client{Timeout: dispatchLLMTimeout}
|
||||
|
||||
// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
|
||||
// two empty strings if the model is unconfigured, errors, times out, or produces
|
||||
// anything malformed. The fact must already have its FINAL Actors set (post
|
||||
// opt-out anonymisation) — that list is the only set of names the prose may use,
|
||||
// and it is what Pete's guard checks the output against.
|
||||
func authorDispatch(f peteclient.Fact) (headline, lede string) {
|
||||
host := os.Getenv("OLLAMA_HOST")
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
if host == "" || model == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
prompt := buildDispatchPrompt(f)
|
||||
raw, err := callOllamaDispatch(host, model, prompt)
|
||||
if err != nil {
|
||||
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
h, l, ok := parseDispatch(raw)
|
||||
if !ok {
|
||||
slog.Warn("pete dispatch: unparseable LLM output, Pete will template", "guid", f.GUID)
|
||||
return "", ""
|
||||
}
|
||||
// Ship only a complete, in-bounds pair. A half-authored dispatch or an
|
||||
// over-long one is exactly what Pete would reject anyway; catch it here so a
|
||||
// bad generation costs nothing on the wire.
|
||||
if h == "" || l == "" || len(h) > maxDispatchHeadline || len(l) > maxDispatchLede {
|
||||
slog.Warn("pete dispatch: LLM output empty or over length, Pete will template",
|
||||
"guid", f.GUID, "headline_len", len(h), "lede_len", len(l))
|
||||
return "", ""
|
||||
}
|
||||
return h, l
|
||||
}
|
||||
|
||||
// buildDispatchPrompt renders the persona, the strict rules, and this fact's
|
||||
// structured facts into a single prompt. The facts block lists only the fields
|
||||
// that are set, each labelled, so the model has the who/what/where and no room
|
||||
// to invent the rest.
|
||||
func buildDispatchPrompt(f peteclient.Fact) string {
|
||||
var facts strings.Builder
|
||||
add := func(label, val string) {
|
||||
if val != "" {
|
||||
fmt.Fprintf(&facts, "- %s: %s\n", label, val)
|
||||
}
|
||||
}
|
||||
addN := func(label string, n int) {
|
||||
if n != 0 {
|
||||
fmt.Fprintf(&facts, "- %s: %d\n", label, n)
|
||||
}
|
||||
}
|
||||
add("event", f.EventType)
|
||||
add("who this is about (the subject)", f.Subject)
|
||||
add("the other person named", f.Opponent)
|
||||
add("monster or boss", f.Boss)
|
||||
add("dungeon or zone", f.Zone)
|
||||
add("region", f.Region)
|
||||
addN("character level", f.Level)
|
||||
addN("count", f.Count)
|
||||
add("outcome", f.Outcome)
|
||||
add("stakes or item", f.Stakes)
|
||||
add("class and race", f.ClassRace)
|
||||
add("milestone", f.Milestone)
|
||||
|
||||
names := "(none — this is a realm-level event with no named adventurer)"
|
||||
if len(f.Actors) > 0 {
|
||||
names = strings.Join(f.Actors, ", ")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`You are Pete, a warm, friendly local news reporter for a fantasy adventuring town. Think a beloved local newscaster who genuinely knows everyone and is glad to see them. You have journalistic bones — a clear headline and a who/what/where lede that gets the facts right — delivered with personable, first-person warmth. You root for the community, celebrate wins, mourn losses gently, welcome newcomers. Conversational, never snarky, never a caps-lock hype-man. Warmth carries the register, not exclamation marks.
|
||||
|
||||
Write a short news dispatch about the event below.
|
||||
|
||||
STRICT RULES — do not violate these:
|
||||
- Use ONLY these adventurer names, exactly as written: %s. Never invent a name, never use any other person's name, never use an @-handle.
|
||||
- Use ONLY the facts listed. Do not invent numbers, outcomes, items, or events that are not below.
|
||||
- The monster/boss, zone, region and item names are game names — you may use them as given.
|
||||
- Do not address the reader as "you" unless the event is Pete's own duel.
|
||||
- No markdown, no emoji, no quotation marks around the whole thing.
|
||||
|
||||
Respond with ONLY a JSON object, no other text:
|
||||
{"headline": "one short sentence, a real headline", "lede": "one to three warm sentences with the who/what/where"}
|
||||
|
||||
The event:
|
||||
%s`, names, facts.String())
|
||||
}
|
||||
|
||||
// callOllamaDispatch posts a single non-streaming generation and returns the raw
|
||||
// completion (think-tags stripped). Its own bounded client, separate from the
|
||||
// interactive callOllama, because the game loop cannot wait 120s on the news.
|
||||
func callOllamaDispatch(host, model, prompt string) (string, error) {
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
"think": false,
|
||||
"options": map[string]interface{}{
|
||||
"num_ctx": 4096,
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
||||
resp, err := dispatchHTTP.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ollama request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var result struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return result.Response, nil
|
||||
}
|
||||
|
||||
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
|
||||
// the usual noise (think blocks, markdown fences, prose around the JSON). ok is
|
||||
// false when no JSON object with a headline can be recovered.
|
||||
func parseDispatch(raw string) (headline, lede string, ok bool) {
|
||||
s := raw
|
||||
// Drop a Qwen-style reasoning block if present.
|
||||
if i := strings.Index(s, "<think>"); i != -1 {
|
||||
if j := strings.Index(s, "</think>"); j != -1 {
|
||||
s = s[:i] + s[j+len("</think>"):]
|
||||
}
|
||||
}
|
||||
// Isolate the first {...} object so surrounding prose or fences don't break
|
||||
// the decode.
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end <= start {
|
||||
return "", "", false
|
||||
}
|
||||
var out struct {
|
||||
Headline string `json:"headline"`
|
||||
Lede string `json:"lede"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s[start:end+1]), &out); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
headline = strings.TrimSpace(out.Headline)
|
||||
lede = strings.TrimSpace(out.Lede)
|
||||
if headline == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return headline, lede, true
|
||||
}
|
||||
81
internal/plugin/pete_dispatch_voice_test.go
Normal file
81
internal/plugin/pete_dispatch_voice_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
)
|
||||
|
||||
func TestParseDispatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantOK bool
|
||||
wantHeadPre string
|
||||
}{
|
||||
{
|
||||
name: "clean json",
|
||||
raw: `{"headline": "Josie cleared the Ossuary.", "lede": "Alone, no less."}`,
|
||||
wantOK: true,
|
||||
wantHeadPre: "Josie cleared",
|
||||
},
|
||||
{
|
||||
name: "wrapped in prose and fences",
|
||||
raw: "Sure! Here you go:\n```json\n{\"headline\":\"A win.\",\"lede\":\"Nice one.\"}\n```",
|
||||
wantOK: true,
|
||||
wantHeadPre: "A win.",
|
||||
},
|
||||
{
|
||||
name: "think block stripped",
|
||||
raw: "<think>let me consider the tone</think>\n{\"headline\":\"Held the line.\",\"lede\":\"Proud of you all.\"}",
|
||||
wantOK: true,
|
||||
wantHeadPre: "Held the line.",
|
||||
},
|
||||
{name: "no json", raw: "I could not write that.", wantOK: false},
|
||||
{name: "empty headline", raw: `{"headline":"","lede":"body"}`, wantOK: false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h, l, ok := parseDispatch(c.raw)
|
||||
if ok != c.wantOK {
|
||||
t.Fatalf("ok = %v, want %v (h=%q l=%q)", ok, c.wantOK, h, l)
|
||||
}
|
||||
if ok && !strings.HasPrefix(h, c.wantHeadPre) {
|
||||
t.Errorf("headline = %q, want prefix %q", h, c.wantHeadPre)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDispatchPrompt pins the two properties the prose-guard depends on:
|
||||
// the allowed names are stated verbatim, and only the set facts appear (no empty
|
||||
// labels for the model to fill in with invention).
|
||||
func TestBuildDispatchPrompt(t *testing.T) {
|
||||
f := peteclient.Fact{
|
||||
EventType: "boss_kill", Subject: "Josie", Boss: "the Bone Warden",
|
||||
Zone: "the Ossuary", Level: 14, Actors: []string{"Josie"},
|
||||
}
|
||||
p := buildDispatchPrompt(f)
|
||||
|
||||
if !strings.Contains(p, "ONLY these adventurer names, exactly as written: Josie") {
|
||||
t.Errorf("prompt does not constrain names to Actors:\n%s", p)
|
||||
}
|
||||
if !strings.Contains(p, "the Bone Warden") || !strings.Contains(p, "the Ossuary") {
|
||||
t.Error("prompt dropped a supplied fact")
|
||||
}
|
||||
// Unset fields must not appear as empty labels.
|
||||
if strings.Contains(p, "region:") || strings.Contains(p, "milestone:") {
|
||||
t.Errorf("prompt lists an unset fact:\n%s", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDispatchPromptRealmEvent: a realm-level fact with no named adventurer
|
||||
// still produces a usable prompt that tells the model there is no name to use.
|
||||
func TestBuildDispatchPromptRealmEvent(t *testing.T) {
|
||||
f := peteclient.Fact{EventType: "siege_start", Boss: "the Horde", Stakes: "the whole town"}
|
||||
p := buildDispatchPrompt(f)
|
||||
if !strings.Contains(p, "no named adventurer") {
|
||||
t.Errorf("realm event prompt missing the no-name note:\n%s", p)
|
||||
}
|
||||
}
|
||||
282
internal/plugin/pete_equip.go
Normal file
282
internal/plugin/pete_equip.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package plugin
|
||||
|
||||
// The web equip queue's game-side loop.
|
||||
//
|
||||
// An owner asks, on their own detail page on Pete, to wear or take off an item.
|
||||
// Pete records the intent; we poll for it, run the real equip against our own
|
||||
// equipment tables, and file a verdict Pete shows them. Same reverse-pipe shape
|
||||
// as mischief — Pete has no route into this box, so we ask for work rather than
|
||||
// being told about it.
|
||||
//
|
||||
// The one thing that is NOT like mischief: the underlying action isn't
|
||||
// idempotent. Equipping consumes an inventory row and unequipping mints a fresh
|
||||
// one, so simply re-running a re-offered order would double-move the item. So
|
||||
// before we touch anything we check the equip_applied_orders ledger: if this
|
||||
// order's guid is already there, the mutation happened on an earlier tick and we
|
||||
// only lost the verdict-ack — we re-file the stored verdict and mutate nothing.
|
||||
// The guid is still the end-to-end key; here it guards a non-idempotent action
|
||||
// instead of riding a naturally idempotent one.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
equipPollInterval = 30 * time.Second
|
||||
equipPollTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// peteEquipTicker polls Pete for equip orders and fulfils them. Started alongside
|
||||
// the other adventure tickers; exits on stopCh.
|
||||
func (p *AdventurePlugin) peteEquipTicker() {
|
||||
if !peteclient.Enabled() {
|
||||
return // no Pete wire configured; the equip queue is simply off
|
||||
}
|
||||
ticker := time.NewTicker(equipPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.pollEquipOrders()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) pollEquipOrders() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), equipPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
orders, err := peteclient.PendingEquip(ctx)
|
||||
if err != nil {
|
||||
// A Pete predating the queue answers 404; a wire blip looks the same. Quiet
|
||||
// on purpose — this must not spam while Pete hasn't shipped the endpoint.
|
||||
slog.Debug("equip: poll failed", "err", err)
|
||||
return
|
||||
}
|
||||
for _, order := range orders {
|
||||
p.fulfilEquipOrder(ctx, order)
|
||||
}
|
||||
}
|
||||
|
||||
// fulfilEquipOrder applies one order and files its verdict. A transient failure is
|
||||
// left pending for the next poll (no verdict); a permanent one gets a specific
|
||||
// rejection. The guid ledger makes a re-offer after a lost ack a no-op that simply
|
||||
// re-files the verdict.
|
||||
func (p *AdventurePlugin) fulfilEquipOrder(ctx context.Context, order peteclient.EquipOrder) {
|
||||
// Already applied on an earlier tick? Re-file the stored verdict, mutate nothing.
|
||||
if status, detail, ok := equipOrderAlreadyApplied(order.GUID); ok {
|
||||
if err := peteclient.VerdictEquip(ctx, order.GUID, status, detail); err != nil {
|
||||
slog.Warn("equip: re-file verdict push failed, will retry next poll",
|
||||
"order", order.GUID, "status", status, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
owner, ok := p.equipOwnerMXID(order.OwnerLocalpart)
|
||||
if !ok {
|
||||
// The client isn't up (tests) or the localpart is empty. Not our order to
|
||||
// fail permanently — leave it pending and try again once we can name the owner.
|
||||
slog.Debug("equip: cannot resolve owner, leaving pending", "order", order.GUID, "owner", order.OwnerLocalpart)
|
||||
return
|
||||
}
|
||||
|
||||
status, detail, retry := p.applyEquipOrder(owner, order)
|
||||
if retry {
|
||||
return // transient; leave pending for the next tick
|
||||
}
|
||||
|
||||
// Record the verdict BEFORE pushing it, so a crash after the mutation still
|
||||
// short-circuits next tick and re-files rather than re-applying. The mutation
|
||||
// and this insert aren't one transaction, but the window between them is a
|
||||
// single statement — the same practical guarantee the DM equip path lives with.
|
||||
if err := recordEquipApplied(order.GUID, status, detail); err != nil {
|
||||
// If we can't record it, don't push the verdict either: leave the order
|
||||
// pending so the ledger and Pete stay in step. Re-running an equip is the
|
||||
// double-move we're guarding against, so a rare re-apply here is the lesser
|
||||
// evil versus a verdict with no ledger behind it. Transient; retry.
|
||||
slog.Warn("equip: failed to record applied order, leaving pending",
|
||||
"order", order.GUID, "status", status, "err", err)
|
||||
return
|
||||
}
|
||||
if err := peteclient.VerdictEquip(ctx, order.GUID, status, detail); err != nil {
|
||||
slog.Warn("equip: verdict push failed, will re-file next poll",
|
||||
"order", order.GUID, "status", status, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("equip: web order fulfilled", "order", order.GUID, "action", order.Action, "status", status)
|
||||
}
|
||||
|
||||
// applyEquipOrder runs the real equip/unequip. It returns the terminal status and
|
||||
// a human note for Pete, or retry=true for a transient fault that should leave the
|
||||
// order pending. It records nothing and pushes nothing — the caller does both.
|
||||
func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) {
|
||||
// Serialize against the owner's own Matrix-side mutations (!give, !equip, !sell,
|
||||
// arena, …), all of which hold this same per-user lock. Without it the poll
|
||||
// goroutine's equip could interleave with a concurrent !give of the very item it
|
||||
// resolved — the duplication the DM equip confirm takes this lock to prevent.
|
||||
userMu := p.advUserLock(owner)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
switch order.Action {
|
||||
case "equip":
|
||||
inv, err := loadAdvInventory(owner)
|
||||
if err != nil {
|
||||
return "", "", true // transient
|
||||
}
|
||||
var it AdvItem
|
||||
found := false
|
||||
for _, cand := range inv {
|
||||
if cand.ID == order.ItemID {
|
||||
it, found = cand, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// The row left the pack before we got here (worn already, sold, a stale
|
||||
// page). The table is AUTOINCREMENT, so the id can't have been reused for
|
||||
// a different item — this is a clean miss, not a wrong hit.
|
||||
return "rejected_not_owned", "That item wasn't in your pack anymore.", false
|
||||
}
|
||||
// Masterwork/arena pieces equip into a standard slot; everything else takes
|
||||
// the magic-item path. Type alone routes it — the id resolved the same row.
|
||||
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
|
||||
out, err := applyMasterworkEquip(owner, it)
|
||||
if errors.Is(err, errItemNotEquippable) {
|
||||
return "rejected_not_equippable", "That item can't be worn.", false
|
||||
}
|
||||
if errors.Is(err, errEquipDowngrade) {
|
||||
return "rejected_downgrade", "That isn't an upgrade over what you're wearing.", false
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", true // transient DB fault
|
||||
}
|
||||
return "applied", masterworkEquipDetail(out), false
|
||||
}
|
||||
out, err := applyMagicEquip(owner, it)
|
||||
if errors.Is(err, errItemNotEquippable) {
|
||||
return "rejected_not_equippable", "That item can't be worn.", false
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", true // transient DB fault
|
||||
}
|
||||
return "applied", equipAppliedDetail(out), false
|
||||
|
||||
case "unequip":
|
||||
// A standard slot (weapon/armor/…) takes a masterwork/arena piece off; a DnD
|
||||
// slot takes a magic item off. The vocabularies are disjoint, so the slot
|
||||
// string alone tells the two apart.
|
||||
if isEquipmentSlot(order.Slot) {
|
||||
out, err := applyMasterworkUnequip(owner, EquipmentSlot(order.Slot))
|
||||
if errors.Is(err, errSlotEmpty) {
|
||||
return "rejected_not_worn", "There was nothing to take off there.", false
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
return "applied", fmt.Sprintf("Took %s off, back in your pack.", out.Name), false
|
||||
}
|
||||
out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
|
||||
if errors.Is(err, errSlotEmpty) {
|
||||
return "rejected_not_worn", "That slot was already empty.", false
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
note := fmt.Sprintf("Took off %s, back in your pack.", out.Item.Name)
|
||||
if len(out.Healed) > 0 {
|
||||
note += fmt.Sprintf(" That freed a bond, so %s is active now.", strings.Join(out.Healed, ", "))
|
||||
}
|
||||
return "applied", note, false
|
||||
|
||||
case "upgrade":
|
||||
return p.purchaseEquipmentTier(owner, EquipmentSlot(order.Slot), order.Tier, order.GUID)
|
||||
|
||||
case "repair":
|
||||
return p.repairSlot(owner, EquipmentSlot(order.Slot), order.GUID)
|
||||
|
||||
default:
|
||||
// Pete validates the action before it ever queues an order, so this is a
|
||||
// contract breach, not a user mistake. Reject permanently rather than spin.
|
||||
return "rejected_not_equippable", "Unknown action.", false
|
||||
}
|
||||
}
|
||||
|
||||
// equipAppliedDetail turns an equip outcome into the plain note Pete shows.
|
||||
func equipAppliedDetail(out magicEquipOutcome) string {
|
||||
b := fmt.Sprintf("Now worn in your %s slot.", out.Effective.Slot)
|
||||
switch {
|
||||
case out.Bonded:
|
||||
b += fmt.Sprintf(" Bonded (%d of %d).", out.BondsBefore+1, dndMagicItemAttuneLimit)
|
||||
case out.AtCap:
|
||||
b += fmt.Sprintf(" Worn but inert: all %d bonds are in use, so take one off to activate it.", dndMagicItemAttuneLimit)
|
||||
}
|
||||
if out.SwappedBack != "" {
|
||||
b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack)
|
||||
}
|
||||
if len(out.Healed) > 0 {
|
||||
b += fmt.Sprintf(" A freed bond also activated %s.", strings.Join(out.Healed, ", "))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// equipOwnerMXID reconstructs the owner's Matrix id from the localpart Pete sent,
|
||||
// the same construction as the mischief buyer's. Fails closed if the client isn't
|
||||
// up (tests) or the name is empty.
|
||||
func (p *AdventurePlugin) equipOwnerMXID(localpart string) (id.UserID, bool) {
|
||||
lp := strings.ToLower(strings.TrimSpace(localpart))
|
||||
if lp == "" || p.Client == nil {
|
||||
return "", false
|
||||
}
|
||||
server := p.Client.UserID.Homeserver()
|
||||
if server == "" {
|
||||
return "", false
|
||||
}
|
||||
return id.NewUserID(lp, server), true
|
||||
}
|
||||
|
||||
// ---- the applied-order ledger --------------------------------------------------
|
||||
|
||||
// equipOrderAlreadyApplied reports the verdict we filed for an order, if we have
|
||||
// already applied it. This is the short-circuit that keeps a re-offered order from
|
||||
// re-running its non-idempotent mutation.
|
||||
func equipOrderAlreadyApplied(guid string) (status, detail string, ok bool) {
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT status, detail FROM equip_applied_orders WHERE guid = ?`, guid,
|
||||
).Scan(&status, &detail)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", false
|
||||
}
|
||||
if err != nil {
|
||||
// A read failure here would send us down the mutation path and risk a
|
||||
// double-move, so treat it as "don't know" and let the caller leave the
|
||||
// order pending rather than assume it's fresh. We signal that by returning
|
||||
// ok=false but... the caller can't tell the difference. Log loudly; a
|
||||
// persistent read failure is a real problem, but a transient one self-heals
|
||||
// on the next poll because the mutation itself is guarded by this same table.
|
||||
slog.Error("equip: applied-ledger read failed", "order", guid, "err", err)
|
||||
return "", "", false
|
||||
}
|
||||
return status, detail, true
|
||||
}
|
||||
|
||||
// recordEquipApplied stamps an order as applied with the verdict we're about to
|
||||
// file. OR IGNORE so a re-file that somehow reaches here can't error on the guid.
|
||||
func recordEquipApplied(guid, status, detail string) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT OR IGNORE INTO equip_applied_orders (guid, status, detail) VALUES (?, ?, ?)`,
|
||||
guid, status, detail)
|
||||
return err
|
||||
}
|
||||
340
internal/plugin/pete_equip_manage.go
Normal file
340
internal/plugin/pete_equip_manage.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package plugin
|
||||
|
||||
// Ask 7: full equipment management from the web.
|
||||
//
|
||||
// The magic-item equip path (magic_items_gameplay.go) only ever touched the DnD
|
||||
// slots — off_hand, rings, and the like — which are almost always empty. Almost
|
||||
// everything a player actually wears lives in the OTHER two systems: the 5
|
||||
// standard EquipmentSlots (weapon/armor/helmet/boots/tool), whose power is the
|
||||
// slot's integer Tier, and the masterwork/arena pieces that get equipped INTO a
|
||||
// standard slot. This file is the game-side of managing all of that from Pete:
|
||||
//
|
||||
// - applyMasterworkEquip / applyMasterworkUnequip — move a masterwork/arena
|
||||
// piece between the pack and a standard slot (no money).
|
||||
// - purchaseEquipmentTier — buy the next standard tier with euros (confirm-gated
|
||||
// on the web), the headless twin of the shop's advBuyEquipment.
|
||||
// - repairSlot — mend a slot's condition with euros, the headless twin of the
|
||||
// blacksmith's executeRepair.
|
||||
// - buildEquipSlotViews — the owner-only snapshot the web panel renders from.
|
||||
//
|
||||
// The two euro-spending mutators run on the retrying poll wire, so every money
|
||||
// move goes through the idempotent euro variants keyed on the order guid: a
|
||||
// re-offered order that already debited skips the charge and just re-runs the
|
||||
// idempotent slot write. That is why neither refunds on a later DB fault — a
|
||||
// refund keyed on a fresh id, followed by a guid-guarded retry that no longer
|
||||
// re-debits, would hand the player both the gear and their money back. The casino
|
||||
// escrow (pete_games.go) settles the same way: idempotent move, then retry.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// errEquipDowngrade is a permanent refusal: the incoming piece is no better than
|
||||
// what is worn. Downgrades are blocked by user decision (equip and upgrade both).
|
||||
var errEquipDowngrade = errors.New("equip: would be a downgrade")
|
||||
|
||||
// mwEquipOutcome is what a masterwork/arena equip did, for the verdict note.
|
||||
type mwEquipOutcome struct {
|
||||
Name string
|
||||
Slot EquipmentSlot
|
||||
Tier int
|
||||
Arena bool
|
||||
SwappedBack string // the special occupant evicted back to the pack, or ""
|
||||
}
|
||||
|
||||
// applyMasterworkEquip wears one masterwork/arena backpack piece into its standard
|
||||
// slot. Ordering is anti-duplication AND safe under the equip poll's 30s retry:
|
||||
// remove the incoming row FIRST (restoring it on a save fault), then write the
|
||||
// slot, and only THEN evict any displaced special occupant back to the pack. The
|
||||
// eviction comes last, once the slot no longer references the occupant, so it can
|
||||
// never mint a duplicate; and it is best-effort — a failure there is logged, not
|
||||
// aborted on, the same tolerance the DM confirm handler (adventure_masterwork.go)
|
||||
// lives with. Aborting after the slot write would strand a completed equip for a
|
||||
// retry that re-evicts the occupant on every tick.
|
||||
func applyMasterworkEquip(uid id.UserID, it AdvItem) (mwEquipOutcome, error) {
|
||||
if it.Slot == "" || (it.Type != "MasterworkGear" && it.Type != "ArenaGear") {
|
||||
return mwEquipOutcome{}, errItemNotEquippable
|
||||
}
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return mwEquipOutcome{}, err
|
||||
}
|
||||
slot := it.Slot
|
||||
cur := equip[slot]
|
||||
|
||||
// Downgrade block: the incoming effective tier must beat the current occupant.
|
||||
incoming := &AdvEquipment{Tier: it.Tier}
|
||||
if it.Type == "ArenaGear" {
|
||||
incoming.ArenaTier = it.Tier
|
||||
} else {
|
||||
incoming.Masterwork = true
|
||||
}
|
||||
if advEffectiveTier(incoming) <= advEffectiveTier(cur) {
|
||||
return mwEquipOutcome{}, errEquipDowngrade
|
||||
}
|
||||
|
||||
// Capture the special occupant to evict, if any, BEFORE the slot write below
|
||||
// mutates cur in place. A plain shop-tier occupant is not an item — it is just
|
||||
// the slot's tier — so it is overwritten, not evicted, the same as the DM confirm
|
||||
// handler and the shop. The actual re-pack happens after the slot write (below),
|
||||
// so it can never duplicate the piece.
|
||||
var evicted *AdvItem
|
||||
if cur != nil && (cur.Masterwork || cur.ArenaTier > 0) {
|
||||
old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource}
|
||||
if cur.ArenaTier > 0 {
|
||||
old.Type = "ArenaGear"
|
||||
}
|
||||
evicted = &old
|
||||
}
|
||||
|
||||
// Destructive op first: pull the incoming row before writing the slot, so a save
|
||||
// fault can't leave it both worn and in the pack. Restore it on failure.
|
||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||
return mwEquipOutcome{}, err
|
||||
}
|
||||
eq := cur
|
||||
if eq == nil {
|
||||
eq = &AdvEquipment{Slot: slot}
|
||||
}
|
||||
eq.Tier = it.Tier
|
||||
eq.Condition = 100
|
||||
eq.Name = it.Name
|
||||
eq.ActionsUsed = 0
|
||||
if it.Type == "ArenaGear" {
|
||||
eq.Masterwork = false
|
||||
eq.SkillSource = ""
|
||||
eq.ArenaTier = it.Tier
|
||||
eq.ArenaSet = ""
|
||||
if gs := arenaGearByName(it.Name); gs != nil {
|
||||
eq.ArenaSet = gs.SetKey
|
||||
}
|
||||
} else {
|
||||
eq.ArenaTier = 0
|
||||
eq.ArenaSet = ""
|
||||
eq.Masterwork = true
|
||||
eq.SkillSource = it.SkillSource
|
||||
}
|
||||
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||
restored := AdvItem{Name: it.Name, Type: it.Type, Tier: it.Tier, Value: it.Value, Slot: it.Slot, SkillSource: it.SkillSource}
|
||||
if rbErr := addAdvInventoryItem(uid, restored); rbErr != nil {
|
||||
slog.Error("equip: masterwork save failed AND inventory rollback failed",
|
||||
"user", uid, "item", it.Name, "save_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return mwEquipOutcome{}, err
|
||||
}
|
||||
|
||||
// The slot now holds the incoming piece, so the former occupant is referenced
|
||||
// nowhere — re-packing it now cannot duplicate it. Best-effort: a failure is a
|
||||
// bounded, non-compounding loss we log rather than abort on, since the equip has
|
||||
// already succeeded and aborting would re-run (and re-evict) on the next poll.
|
||||
var swappedBack string
|
||||
if evicted != nil {
|
||||
if err := addAdvInventoryItem(uid, *evicted); err != nil {
|
||||
slog.Error("equip: masterwork equipped but evicted piece failed to return to pack",
|
||||
"user", uid, "evicted", evicted.Name, "err", err)
|
||||
} else {
|
||||
swappedBack = evicted.Name
|
||||
}
|
||||
}
|
||||
return mwEquipOutcome{Name: it.Name, Slot: slot, Tier: it.Tier, Arena: it.Type == "ArenaGear", SwappedBack: swappedBack}, nil
|
||||
}
|
||||
|
||||
// mwUnequipOutcome is what a masterwork/arena take-off did.
|
||||
type mwUnequipOutcome struct {
|
||||
Name string
|
||||
Slot EquipmentSlot
|
||||
}
|
||||
|
||||
// applyMasterworkUnequip takes a worn masterwork/arena piece off a standard slot,
|
||||
// returns it to the pack, and resets the slot to its tier-0 default. A plain
|
||||
// shop-tier slot has nothing round-trippable (its tier is not an item), so that is
|
||||
// errSlotEmpty — reverting a shop tier is not a take-off. The 5 slot rows are an
|
||||
// invariant (PK user_id+slot), so the row is reset, never deleted. Destructive op
|
||||
// first — reset the slot, then mint the pack row, restoring the slot on failure —
|
||||
// mirroring the magic unequip so a fault can't duplicate the piece.
|
||||
func applyMasterworkUnequip(uid id.UserID, slot EquipmentSlot) (mwUnequipOutcome, error) {
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return mwUnequipOutcome{}, err
|
||||
}
|
||||
cur := equip[slot]
|
||||
if cur == nil || (!cur.Masterwork && cur.ArenaTier == 0) {
|
||||
return mwUnequipOutcome{}, errSlotEmpty
|
||||
}
|
||||
prev := *cur // snapshot for rollback
|
||||
|
||||
def0 := equipmentTiers[slot][0]
|
||||
reset := &AdvEquipment{Slot: slot, Tier: 0, Condition: 100, Name: def0.Name, ActionsUsed: 0, ArenaTier: 0, ArenaSet: "", Masterwork: false, SkillSource: ""}
|
||||
if err := saveAdvEquipment(uid, reset); err != nil {
|
||||
return mwUnequipOutcome{}, err
|
||||
}
|
||||
|
||||
old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource}
|
||||
if cur.ArenaTier > 0 {
|
||||
old.Type = "ArenaGear"
|
||||
}
|
||||
if err := addAdvInventoryItem(uid, old); err != nil {
|
||||
if rbErr := saveAdvEquipment(uid, &prev); rbErr != nil {
|
||||
slog.Error("equip: masterwork take-off failed AND slot rollback failed",
|
||||
"user", uid, "slot", slot, "add_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return mwUnequipOutcome{}, err
|
||||
}
|
||||
return mwUnequipOutcome{Name: cur.Name, Slot: slot}, nil
|
||||
}
|
||||
|
||||
// purchaseEquipmentTier buys a standard slot's tier with euros — the headless twin
|
||||
// of advBuyEquipment, minus flavor. It returns a terminal verdict for Pete or
|
||||
// retry=true for a transient fault. Money moves once, keyed on the order guid; the
|
||||
// web only ever offers the next tier over a PLAIN shop-tier slot (buildEquipSlotViews
|
||||
// suppresses the offer on special gear), so there is no occupant to evict here and
|
||||
// the whole body is idempotent under a re-offered order.
|
||||
func (p *AdventurePlugin) purchaseEquipmentTier(uid id.UserID, slot EquipmentSlot, tier int, guid string) (status, detail string, retry bool) {
|
||||
defs, ok := equipmentTiers[slot]
|
||||
if !ok {
|
||||
return "rejected_not_equippable", "That isn't an equipment slot.", false
|
||||
}
|
||||
if tier < 1 || tier >= len(defs) {
|
||||
// tier 0 is the free default, not a purchase; >= len is past the top tier.
|
||||
return "rejected_max_tier", "That slot is already at the top tier.", false
|
||||
}
|
||||
def := defs[tier]
|
||||
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
cur := equip[slot]
|
||||
if cur != nil {
|
||||
// Buying a shop tier over a special piece strips its bonus — a downgrade in
|
||||
// practice even when the raw number rises. Take it off first, then buy.
|
||||
if cur.Masterwork || cur.ArenaTier > 0 {
|
||||
return "rejected_downgrade", "Take off your special gear in that slot before buying a tier.", false
|
||||
}
|
||||
if cur.Tier >= def.Tier {
|
||||
return "rejected_downgrade", "You already have that tier or better.", false
|
||||
}
|
||||
}
|
||||
|
||||
price := def.Price
|
||||
if !p.euro.HasExternalTx(guid) {
|
||||
ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", guid)
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
if !ok {
|
||||
return "rejected_insufficient_funds", fmt.Sprintf("That upgrade costs €%.0f and you can't cover it.", price), false
|
||||
}
|
||||
}
|
||||
|
||||
eq := &AdvEquipment{Slot: slot, Tier: def.Tier, Condition: 100, Name: def.Name, ActionsUsed: 0}
|
||||
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||
// No refund: the debit is guid-idempotent, so the next poll re-runs this with
|
||||
// the charge already settled and only the (idempotent) slot write left to do.
|
||||
// Refunding here would double-pay once that retry lands the gear.
|
||||
return "", "", true
|
||||
}
|
||||
return "applied", fmt.Sprintf("Upgraded your %s to %s (T%d) for €%.0f.", slot, def.Name, def.Tier, price), false
|
||||
}
|
||||
|
||||
// repairSlot mends one standard slot's condition with euros — the headless twin of
|
||||
// the blacksmith's executeRepair. Idempotent on the order guid: the debit runs
|
||||
// once, and setting condition to 100 is itself idempotent, so a re-offered order is
|
||||
// safe with no refund.
|
||||
func (p *AdventurePlugin) repairSlot(uid id.UserID, slot EquipmentSlot, guid string) (status, detail string, retry bool) {
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
eq := equip[slot]
|
||||
if eq == nil {
|
||||
return "rejected_not_worn", "There's nothing in that slot to repair.", false
|
||||
}
|
||||
cost := blacksmithRepairCost(eq)
|
||||
if cost <= 0 {
|
||||
// Already full — nothing to charge for. Report it as applied so the order
|
||||
// reaches a terminal state rather than parking.
|
||||
return "applied", "That piece was already at full condition.", false
|
||||
}
|
||||
if !p.euro.HasExternalTx(guid) {
|
||||
ok, _, err := p.euro.DebitIdem(uid, float64(cost), "adventure_repair", guid)
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
if !ok {
|
||||
return "rejected_insufficient_funds", fmt.Sprintf("The repair costs €%d and you can't cover it.", cost), false
|
||||
}
|
||||
}
|
||||
eq.Condition = 100
|
||||
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||
return "", "", true // retry; the idempotent debit means no double-charge
|
||||
}
|
||||
return "applied", fmt.Sprintf("Repaired your %s for €%d.", eq.Name, cost), false
|
||||
}
|
||||
|
||||
// buildEquipSlotViews is the owner-only snapshot of the 5 standard slots the web
|
||||
// management panel renders from. Worn masterwork/arena pieces surface here (via
|
||||
// CanTakeOff), not in the magic Equipped set. An upgrade is offered only over a
|
||||
// plain shop-tier slot below max — a special piece is taken off, not shop-upgraded.
|
||||
func buildEquipSlotViews(uid id.UserID) []peteclient.EquipSlotView {
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []peteclient.EquipSlotView
|
||||
for _, slot := range allSlots {
|
||||
eq := equip[slot]
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
v := peteclient.EquipSlotView{
|
||||
Slot: string(slot),
|
||||
Name: eq.Name,
|
||||
Tier: eq.Tier,
|
||||
Condition: eq.Condition,
|
||||
Masterwork: eq.Masterwork,
|
||||
ArenaTier: eq.ArenaTier,
|
||||
CanTakeOff: eq.Masterwork || eq.ArenaTier > 0,
|
||||
RepairCost: blacksmithRepairCost(eq),
|
||||
}
|
||||
if !eq.Masterwork && eq.ArenaTier == 0 && eq.Tier < 5 {
|
||||
next := equipmentTiers[slot][eq.Tier+1]
|
||||
v.NextTier = next.Tier
|
||||
v.NextName = next.Name
|
||||
v.NextPrice = next.Price
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// masterworkEquipDetail turns a masterwork/arena equip outcome into the verdict
|
||||
// note Pete shows.
|
||||
func masterworkEquipDetail(out mwEquipOutcome) string {
|
||||
kind := "masterwork"
|
||||
if out.Arena {
|
||||
kind = "arena"
|
||||
}
|
||||
b := fmt.Sprintf("Now worn in your %s slot (%s T%d).", out.Slot, kind, out.Tier)
|
||||
if out.SwappedBack != "" {
|
||||
b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// isEquipmentSlot reports whether a slot string names one of the 5 standard slots.
|
||||
// The magic DnD slots and the standard slots are disjoint vocabularies, so this
|
||||
// alone routes an unequip to the right path.
|
||||
func isEquipmentSlot(slot string) bool {
|
||||
for _, s := range allSlots {
|
||||
if string(s) == slot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
314
internal/plugin/pete_equip_manage_test.go
Normal file
314
internal/plugin/pete_equip_manage_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Ask 7: the headless equipment mutators the web equip queue drives. These pin the
|
||||
// rules that cross the wire — downgrade block, max tier, insufficient funds,
|
||||
// idempotent replay (one debit), masterwork evict/overwrite/take-off — at the
|
||||
// gogobee end, on real rows.
|
||||
|
||||
// seedEquipPlayer stands up a playable adventurer (player_meta + tier-0 gear) with
|
||||
// a euro plugin funded to `bankroll`, and returns the wired AdventurePlugin.
|
||||
func seedEquipPlayer(t *testing.T, uid id.UserID, bankroll float64) *AdventurePlugin {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, "Rurina"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
if bankroll > 0 {
|
||||
euro.Credit(uid, bankroll, "test bankroll")
|
||||
}
|
||||
return &AdventurePlugin{euro: euro}
|
||||
}
|
||||
|
||||
func slotOf(t *testing.T, uid id.UserID, slot EquipmentSlot) *AdvEquipment {
|
||||
t.Helper()
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("loadAdvEquipment: %v", err)
|
||||
}
|
||||
return equip[slot]
|
||||
}
|
||||
|
||||
// TestPurchaseEquipmentTierHappyAndIdempotentDebit: buying the next tier debits
|
||||
// once and raises the slot, and the euro move is keyed on the order guid so a
|
||||
// re-offer moves no money. (A re-offer never re-enters this function in prod — the
|
||||
// equip_applied_orders ledger short-circuits it — but the guid is the belt to that
|
||||
// suspenders, and the retry-after-save-fault path below leans on it directly.)
|
||||
func TestPurchaseEquipmentTierHappyAndIdempotentDebit(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
p := seedEquipPlayer(t, uid, 100000)
|
||||
|
||||
before := p.euro.GetBalance(uid)
|
||||
price := equipmentTiers[SlotBoots][1].Price // Dead Man's Boots, €75
|
||||
|
||||
status, _, retry := p.purchaseEquipmentTier(uid, SlotBoots, 1, "guid-up-1")
|
||||
if retry || status != "applied" {
|
||||
t.Fatalf("upgrade = %q retry=%v, want applied", status, retry)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotBoots); got.Tier != 1 || got.Name != equipmentTiers[SlotBoots][1].Name {
|
||||
t.Fatalf("boots slot = %+v, want tier 1", got)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != before-price {
|
||||
t.Fatalf("balance = %.2f, want %.2f (one debit of %.2f)", got, before-price, price)
|
||||
}
|
||||
// The guid is now a settled money move: a replayed debit on it is a no-op.
|
||||
if !p.euro.HasExternalTx("guid-up-1") {
|
||||
t.Fatal("the upgrade debit was not logged under the order guid")
|
||||
}
|
||||
if ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", "guid-up-1"); err != nil || !ok {
|
||||
t.Fatalf("replayed debit = ok:%v err:%v, want a no-op ok", ok, err)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != before-price {
|
||||
t.Fatalf("replayed debit double-charged: balance = %.2f, want %.2f", got, before-price)
|
||||
}
|
||||
|
||||
// The retry-after-save-fault path: a prior attempt debited but its slot write
|
||||
// never landed, so the slot is still tier 0. The retry must skip the debit
|
||||
// (guid already settled) and finish the write, moving no further money.
|
||||
helmetGUID := "guid-helm"
|
||||
hprice := equipmentTiers[SlotHelmet][1].Price
|
||||
if ok, _, err := p.euro.DebitIdem(uid, hprice, "adventure_equip_upgrade", helmetGUID); err != nil || !ok {
|
||||
t.Fatalf("seed prior debit = ok:%v err:%v", ok, err)
|
||||
}
|
||||
mid := p.euro.GetBalance(uid)
|
||||
status, _, retry = p.purchaseEquipmentTier(uid, SlotHelmet, 1, helmetGUID)
|
||||
if retry || status != "applied" {
|
||||
t.Fatalf("retry after fault = %q retry=%v, want applied", status, retry)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotHelmet); got.Tier != 1 {
|
||||
t.Fatalf("helmet not upgraded on retry: tier %d", got.Tier)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != mid {
|
||||
t.Fatalf("retry re-debited: balance %.2f → %.2f", mid, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurchaseEquipmentTierRejections: a downgrade, a special-gear slot, the top
|
||||
// tier, and an empty wallet all bounce without moving the slot or the money.
|
||||
func TestPurchaseEquipmentTierRejections(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
p := seedEquipPlayer(t, uid, 100000)
|
||||
|
||||
// Lift boots to tier 3 so we have something to (not) downgrade from.
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 3, "seed-t3"); s != "applied" {
|
||||
t.Fatalf("seed to tier 3 = %q", s)
|
||||
}
|
||||
// Same tier or lower is a downgrade.
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 3, "g-eq"); s != "rejected_downgrade" {
|
||||
t.Errorf("re-buy same tier = %q, want rejected_downgrade", s)
|
||||
}
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 2, "g-down"); s != "rejected_downgrade" {
|
||||
t.Errorf("buy lower tier = %q, want rejected_downgrade", s)
|
||||
}
|
||||
// Past the top tier.
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 6, "g-max"); s != "rejected_max_tier" {
|
||||
t.Errorf("buy tier 6 = %q, want rejected_max_tier", s)
|
||||
}
|
||||
// A special piece in the slot: buying a plain tier over it strips the bonus.
|
||||
weapon := slotOf(t, uid, SlotWeapon)
|
||||
weapon.Masterwork = true
|
||||
weapon.Tier = 2
|
||||
weapon.Name = "Miner's Masterwork Blade"
|
||||
weapon.SkillSource = "mining"
|
||||
if err := saveAdvEquipment(uid, weapon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotWeapon, 3, "g-special"); s != "rejected_downgrade" {
|
||||
t.Errorf("buy plain over masterwork = %q, want rejected_downgrade", s)
|
||||
}
|
||||
|
||||
// Insufficient funds: a broke player can't buy a €30000 tier-5 weapon.
|
||||
broke := id.UserID("@broke:test")
|
||||
pb := seedEquipPlayer(t, broke, 0)
|
||||
bal := pb.euro.GetBalance(broke)
|
||||
if s, _, _ := pb.purchaseEquipmentTier(broke, SlotWeapon, 5, "g-broke"); s != "rejected_insufficient_funds" {
|
||||
t.Errorf("broke upgrade = %q, want rejected_insufficient_funds", s)
|
||||
}
|
||||
if got := pb.euro.GetBalance(broke); got != bal {
|
||||
t.Errorf("a rejected upgrade moved money: %.2f → %.2f", bal, got)
|
||||
}
|
||||
if got := slotOf(t, broke, SlotWeapon); got.Tier != 0 {
|
||||
t.Errorf("a rejected upgrade changed the slot: tier %d", got.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairSlotHappyAndNoop: repairing a damaged slot debits the blacksmith cost
|
||||
// and restores condition; repairing a full slot is a no-op that charges nothing.
|
||||
func TestRepairSlotHappyAndNoop(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
p := seedEquipPlayer(t, uid, 100000)
|
||||
|
||||
weapon := slotOf(t, uid, SlotWeapon)
|
||||
weapon.Condition = 50
|
||||
if err := saveAdvEquipment(uid, weapon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cost := blacksmithRepairCost(weapon)
|
||||
if cost <= 0 {
|
||||
t.Fatalf("expected a positive repair cost, got %d", cost)
|
||||
}
|
||||
before := p.euro.GetBalance(uid)
|
||||
|
||||
status, _, retry := p.repairSlot(uid, SlotWeapon, "guid-rep-1")
|
||||
if retry || status != "applied" {
|
||||
t.Fatalf("repair = %q retry=%v, want applied", status, retry)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotWeapon); got.Condition != 100 {
|
||||
t.Fatalf("condition = %d, want 100", got.Condition)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != before-float64(cost) {
|
||||
t.Fatalf("balance = %.2f, want %.2f (debit %d)", got, before-float64(cost), cost)
|
||||
}
|
||||
|
||||
// Now at full condition: a fresh repair is an applied no-op, no charge.
|
||||
after := p.euro.GetBalance(uid)
|
||||
status, _, _ = p.repairSlot(uid, SlotWeapon, "guid-rep-2")
|
||||
if status != "applied" {
|
||||
t.Fatalf("no-op repair = %q, want applied", status)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != after {
|
||||
t.Errorf("no-op repair charged: %.2f → %.2f", after, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMasterworkEquipEvictsOverwritesAndTakesOff: a masterwork piece equips into a
|
||||
// plain slot (overwriting the tier), a better one evicts the special occupant back
|
||||
// to the pack, a worse one is blocked, and take-off resets the slot to tier 0.
|
||||
func TestMasterworkEquipEvictsOverwritesAndTakesOff(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
seedEquipPlayer(t, uid, 0)
|
||||
|
||||
mw := func(name string, tier int) AdvItem {
|
||||
if err := addAdvInventoryItem(uid, AdvItem{Name: name, Type: "MasterworkGear", Tier: tier, Slot: SlotWeapon, SkillSource: "mining"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, _ := loadAdvInventory(uid)
|
||||
for _, it := range inv {
|
||||
if it.Name == name {
|
||||
return it
|
||||
}
|
||||
}
|
||||
t.Fatalf("just-added item %q not in inventory", name)
|
||||
return AdvItem{}
|
||||
}
|
||||
|
||||
// Equip a T3 masterwork over the tier-0 plain weapon: it overwrites, no eviction.
|
||||
out, err := applyMasterworkEquip(uid, mw("Deepforged Blade", 3))
|
||||
if err != nil {
|
||||
t.Fatalf("equip T3: %v", err)
|
||||
}
|
||||
if out.SwappedBack != "" {
|
||||
t.Errorf("plain occupant should be overwritten, not evicted; got swap %q", out.SwappedBack)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotWeapon); !got.Masterwork || got.Tier != 3 || got.Name != "Deepforged Blade" {
|
||||
t.Fatalf("weapon = %+v, want masterwork T3 Deepforged Blade", got)
|
||||
}
|
||||
|
||||
// A better masterwork (T4) evicts the T3 back to the pack.
|
||||
out, err = applyMasterworkEquip(uid, mw("Sunforged Blade", 4))
|
||||
if err != nil {
|
||||
t.Fatalf("equip T4: %v", err)
|
||||
}
|
||||
if out.SwappedBack != "Deepforged Blade" {
|
||||
t.Errorf("evicted = %q, want Deepforged Blade", out.SwappedBack)
|
||||
}
|
||||
invHas := func(name string) bool {
|
||||
inv, _ := loadAdvInventory(uid)
|
||||
for _, it := range inv {
|
||||
if it.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !invHas("Deepforged Blade") {
|
||||
t.Error("the evicted T3 masterwork is not back in the pack")
|
||||
}
|
||||
|
||||
// A worse masterwork (T2) is a blocked downgrade.
|
||||
if _, err := applyMasterworkEquip(uid, mw("Rusty Masterwork", 2)); err != errEquipDowngrade {
|
||||
t.Errorf("equip worse masterwork err = %v, want errEquipDowngrade", err)
|
||||
}
|
||||
|
||||
// Take off the worn T4: it returns to the pack and the slot resets to tier 0.
|
||||
un, err := applyMasterworkUnequip(uid, SlotWeapon)
|
||||
if err != nil {
|
||||
t.Fatalf("take off: %v", err)
|
||||
}
|
||||
if un.Name != "Sunforged Blade" {
|
||||
t.Errorf("took off %q, want Sunforged Blade", un.Name)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotWeapon); got.Masterwork || got.Tier != 0 || got.Name != equipmentTiers[SlotWeapon][0].Name {
|
||||
t.Fatalf("weapon after take-off = %+v, want tier-0 default", got)
|
||||
}
|
||||
if !invHas("Sunforged Blade") {
|
||||
t.Error("the taken-off masterwork is not back in the pack")
|
||||
}
|
||||
|
||||
// Taking off a plain slot has nothing round-trippable.
|
||||
if _, err := applyMasterworkUnequip(uid, SlotArmor); err != errSlotEmpty {
|
||||
t.Errorf("take off plain slot err = %v, want errSlotEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildEquipSlotViews: the panel snapshot offers an upgrade only over a plain
|
||||
// sub-max slot, take-off only on special gear, and a repair cost only when damaged.
|
||||
func TestBuildEquipSlotViews(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
seedEquipPlayer(t, uid, 0)
|
||||
|
||||
// Boots: masterwork T3, damaged → take off + repair, no upgrade offer.
|
||||
boots := slotOf(t, uid, SlotBoots)
|
||||
boots.Masterwork = true
|
||||
boots.Tier = 3
|
||||
boots.Name = "The Wandering Sole"
|
||||
boots.Condition = 70
|
||||
if err := saveAdvEquipment(uid, boots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Helmet: plain T2 → upgrade to T3 offered, no take off, no repair.
|
||||
helmet := slotOf(t, uid, SlotHelmet)
|
||||
helmet.Tier = 2
|
||||
helmet.Name = equipmentTiers[SlotHelmet][2].Name
|
||||
if err := saveAdvEquipment(uid, helmet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
views := buildEquipSlotViews(uid)
|
||||
bySlot := map[string]struct {
|
||||
takeOff bool
|
||||
nextTier int
|
||||
repair int
|
||||
}{}
|
||||
for _, v := range views {
|
||||
bySlot[v.Slot] = struct {
|
||||
takeOff bool
|
||||
nextTier int
|
||||
repair int
|
||||
}{v.CanTakeOff, v.NextTier, v.RepairCost}
|
||||
}
|
||||
if len(views) != len(allSlots) {
|
||||
t.Fatalf("got %d slot views, want %d", len(views), len(allSlots))
|
||||
}
|
||||
if b := bySlot["boots"]; !b.takeOff || b.nextTier != 0 || b.repair <= 0 {
|
||||
t.Errorf("boots view = %+v, want take-off, no upgrade, positive repair", b)
|
||||
}
|
||||
if h := bySlot["helmet"]; h.takeOff || h.nextTier != 3 || h.repair != 0 {
|
||||
t.Errorf("helmet view = %+v, want upgrade to T3, no take-off, no repair", h)
|
||||
}
|
||||
// A pristine tier-0 slot: upgrade offered to T1, no take-off, no repair.
|
||||
if w := bySlot["weapon"]; w.takeOff || w.nextTier != 1 || w.repair != 0 {
|
||||
t.Errorf("weapon view = %+v, want upgrade to T1", w)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
86
internal/plugin/pete_mischief.go
Normal file
86
internal/plugin/pete_mischief.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package plugin
|
||||
|
||||
// The mischief storefront's game-side loop.
|
||||
//
|
||||
// A buyer places a hit on Pete's web board; Pete records the intent and waits.
|
||||
// We poll for those orders, do the real work against our own ledger — the money,
|
||||
// the eligibility, the contract — and hand back a verdict Pete files against the
|
||||
// order. Pete has no route into this box, which is why the traffic runs this way:
|
||||
// we ask for work, we don't get told about it.
|
||||
//
|
||||
// The order guid is the idempotency key end to end (see placeWebMischief), so
|
||||
// every step here is safe to repeat. That is the whole reason the loop can be
|
||||
// this simple: a failed claim just leaves the order pending, and the next tick
|
||||
// re-runs it as a no-op. The loop is its own retry, and needs no durable queue.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
)
|
||||
|
||||
const (
|
||||
mischiefPollInterval = 30 * time.Second
|
||||
mischiefPollTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// peteMischiefTicker polls Pete for storefront orders and fulfils them. Started
|
||||
// alongside the other adventure tickers; exits on stopCh.
|
||||
func (p *AdventurePlugin) peteMischiefTicker() {
|
||||
if !peteclient.Enabled() {
|
||||
return // no Pete wire configured; the storefront half is simply off
|
||||
}
|
||||
ticker := time.NewTicker(mischiefPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.pollMischiefOrders()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pollMischiefOrders fetches the pending orders and fulfils each in turn.
|
||||
func (p *AdventurePlugin) pollMischiefOrders() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mischiefPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
orders, err := peteclient.PendingMischief(ctx)
|
||||
if err != nil {
|
||||
// A Pete that predates the storefront answers 404 here; a wire blip is the
|
||||
// same. Either way there is nothing to do this tick, and the next one tries
|
||||
// again. Debug, not warn — this must be quiet when Pete simply hasn't
|
||||
// shipped the endpoint yet.
|
||||
slog.Debug("mischief: poll failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, order := range orders {
|
||||
p.fulfilMischiefOrder(ctx, order)
|
||||
}
|
||||
}
|
||||
|
||||
// fulfilMischiefOrder places one order's contract and files the verdict. A
|
||||
// transient failure (Retry) is left pending for the next poll; a real verdict is
|
||||
// pushed back so the buyer sees why. A failed claim-push is not fatal — the order
|
||||
// stays pending and the next poll re-runs it, which the guid makes a no-op.
|
||||
func (p *AdventurePlugin) fulfilMischiefOrder(ctx context.Context, order peteclient.MischiefOrder) {
|
||||
res := p.placeWebMischief(order)
|
||||
if res.Retry {
|
||||
return // leave it pending; try again next tick
|
||||
}
|
||||
if err := peteclient.ClaimMischief(ctx, order.GUID, res.Status, res.Detail); err != nil {
|
||||
// The contract is placed (or the buyer refunded) on our side, but Pete
|
||||
// hasn't heard the verdict. It stays pending there and we'll re-offer it;
|
||||
// placeWebMischief will short-circuit on the stamped order guid and we'll
|
||||
// re-file. So this is a warn, not a lost order.
|
||||
slog.Warn("mischief: claim verdict push failed, will re-file next poll",
|
||||
"order", order.GUID, "status", res.Status, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("mischief: web order fulfilled", "order", order.GUID, "status", res.Status)
|
||||
}
|
||||
194
internal/plugin/pete_mischief_test.go
Normal file
194
internal/plugin/pete_mischief_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// webMischiefPlugin builds a plugin the web placement path can run against: a
|
||||
// euro ledger, a capture sink so DMs go nowhere, and a bare client carrying only
|
||||
// a user id — enough for mischiefBuyerMXID to read the homeserver, never used for
|
||||
// a real call (DisplayName fast-fails to the localpart, sends hit the sink).
|
||||
func webMischiefPlugin(t *testing.T) *AdventurePlugin {
|
||||
t.Helper()
|
||||
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||
p.Sink = &captureSink{}
|
||||
// A well-formed client pointed at an unroutable host: mischiefBuyerMXID reads
|
||||
// only its user id (for the homeserver), and the DM courtesy path's lone real
|
||||
// call — GetDisplayName — fails fast against 127.0.0.1:1 and falls back to the
|
||||
// localpart, so nothing here touches a network or hangs.
|
||||
cli, err := mautrix.NewClient("http://127.0.0.1:1", id.UserID("@gogobee:example.org"), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Client = cli
|
||||
return p
|
||||
}
|
||||
|
||||
func webOrder(guid, buyer, targetToken, tier string, signed bool) peteclient.MischiefOrder {
|
||||
return peteclient.MischiefOrder{
|
||||
GUID: guid, BuyerUsername: buyer, TargetToken: targetToken,
|
||||
TargetName: "Josie", Tier: tier, Signed: signed,
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRosterToken round-trips the one-way board token: gogobee can't
|
||||
// invert it, but recomputing every live player's token finds the match, which is
|
||||
// how a web order names its mark.
|
||||
func TestResolveRosterToken(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@josie:example.org")
|
||||
seedMischiefTarget(t, uid, 5, 60)
|
||||
|
||||
tok := eventToken(uid, "roster")
|
||||
got, ok := resolveRosterToken(tok)
|
||||
if !ok || got != uid {
|
||||
t.Fatalf("resolveRosterToken(%s) = %q, %v; want %s", tok, got, ok, uid)
|
||||
}
|
||||
if _, ok := resolveRosterToken("not-a-real-token"); ok {
|
||||
t.Error("resolveRosterToken matched a token no player owns")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefHappyPath: a funded buyer, a mark on a live expedition, a
|
||||
// tier gogobee offers — a web-sourced contract opens, stamped with the order.
|
||||
func TestPlaceWebMischiefHappyPath(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
target := id.UserID("@josie:example.org")
|
||||
seedMischiefTarget(t, target, 5, 60)
|
||||
buyer := id.UserID("@reala:example.org")
|
||||
p.euro.Credit(buyer, 1000, "test seed")
|
||||
|
||||
order := webOrder("ord-1", "reala", eventToken(target, "roster"), "grunt", false)
|
||||
res := p.placeWebMischief(order)
|
||||
|
||||
if res.Status != mischiefWebPlaced {
|
||||
t.Fatalf("status = %q (%s), want placed", res.Status, res.Detail)
|
||||
}
|
||||
c := mischiefContractByOrderGUID("ord-1")
|
||||
if c == nil {
|
||||
t.Fatal("no contract stamped with the order guid")
|
||||
}
|
||||
if c.Source != "web" || c.TargetID != target || c.BuyerID != buyer {
|
||||
t.Fatalf("contract = %+v", c)
|
||||
}
|
||||
if bal := p.euro.GetBalance(buyer); bal != 960 {
|
||||
t.Errorf("buyer balance = %v, want 960 (1000 - 40 grunt)", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefIdempotent is the whole point of keying on the order guid:
|
||||
// the poll loop retries, so a second placement of the same order must not open a
|
||||
// second contract or debit the buyer twice.
|
||||
func TestPlaceWebMischiefIdempotent(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
target := id.UserID("@josie:example.org")
|
||||
seedMischiefTarget(t, target, 5, 60)
|
||||
buyer := id.UserID("@reala:example.org")
|
||||
p.euro.Credit(buyer, 1000, "test seed")
|
||||
|
||||
order := webOrder("ord-dup", "reala", eventToken(target, "roster"), "mob", false)
|
||||
if res := p.placeWebMischief(order); res.Status != mischiefWebPlaced {
|
||||
t.Fatalf("first placement = %q", res.Status)
|
||||
}
|
||||
balAfterFirst := p.euro.GetBalance(buyer)
|
||||
|
||||
// Same order again — the retry.
|
||||
if res := p.placeWebMischief(order); res.Status != mischiefWebPlaced {
|
||||
t.Fatalf("replay = %q, want placed", res.Status)
|
||||
}
|
||||
if bal := p.euro.GetBalance(buyer); bal != balAfterFirst {
|
||||
t.Errorf("replay moved money: balance %v -> %v", balAfterFirst, bal)
|
||||
}
|
||||
|
||||
// Exactly one contract exists for this target.
|
||||
var n int
|
||||
_ = db.Get().QueryRow(`SELECT COUNT(*) FROM mischief_contracts WHERE order_guid = ?`, "ord-dup").Scan(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("order opened %d contracts, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefBouncedFunds: a broke buyer is turned away before any money
|
||||
// moves — a mischief buy takes no credit, same as the Matrix path.
|
||||
func TestPlaceWebMischiefBouncedFunds(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
target := id.UserID("@josie:example.org")
|
||||
seedMischiefTarget(t, target, 5, 60)
|
||||
buyer := id.UserID("@broke:example.org")
|
||||
p.euro.Credit(buyer, 10, "test seed") // a grunt is 40
|
||||
|
||||
order := webOrder("ord-broke", "broke", eventToken(target, "roster"), "grunt", false)
|
||||
res := p.placeWebMischief(order)
|
||||
|
||||
if res.Status != mischiefWebBouncedFunds {
|
||||
t.Fatalf("status = %q, want bounced_funds", res.Status)
|
||||
}
|
||||
if mischiefContractByOrderGUID("ord-broke") != nil {
|
||||
t.Error("a bounced order still opened a contract")
|
||||
}
|
||||
if bal := p.euro.GetBalance(buyer); bal != 10 {
|
||||
t.Errorf("balance = %v, want 10 untouched (no debt for a mischief buy)", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefBouncedIneligibleRefunds: a mark who isn't on an expedition
|
||||
// can't be hit; the buyer is refunded whole, net zero.
|
||||
func TestPlaceWebMischiefBouncedIneligibleRefunds(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
// A real, alive character but NOT on an expedition — ineligible.
|
||||
idle := id.UserID("@idle:example.org")
|
||||
if err := createAdvCharacter(idle, "idle"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveDnDCharacter(&DnDCharacter{
|
||||
UserID: idle, Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||
HPMax: 40, HPCurrent: 40, ArmorClass: 16,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buyer := id.UserID("@reala:example.org")
|
||||
p.euro.Credit(buyer, 1000, "test seed")
|
||||
|
||||
order := webOrder("ord-inelig", "reala", eventToken(idle, "roster"), "elite", false)
|
||||
res := p.placeWebMischief(order)
|
||||
|
||||
if res.Status != mischiefWebBouncedIneligible {
|
||||
t.Fatalf("status = %q (%s), want bounced_ineligible", res.Status, res.Detail)
|
||||
}
|
||||
if bal := p.euro.GetBalance(buyer); bal != 1000 {
|
||||
t.Errorf("buyer balance = %v, want 1000 (debited then refunded whole)", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefSelfTarget: the reconstructed buyer and the resolved target
|
||||
// are the same person — refused, like the Matrix path.
|
||||
func TestPlaceWebMischiefSelfTarget(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
me := id.UserID("@reala:example.org")
|
||||
seedMischiefTarget(t, me, 5, 60)
|
||||
p.euro.Credit(me, 1000, "test seed")
|
||||
|
||||
order := webOrder("ord-self", "reala", eventToken(me, "roster"), "grunt", false)
|
||||
if res := p.placeWebMischief(order); res.Status != mischiefWebBouncedIneligible {
|
||||
t.Fatalf("self-target status = %q, want bounced_ineligible", res.Status)
|
||||
}
|
||||
if bal := p.euro.GetBalance(me); bal != 1000 {
|
||||
t.Errorf("self-target moved money: balance %v", bal)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -48,6 +51,7 @@ func (p *AdventurePlugin) peteRosterTicker() {
|
||||
continue // master switch off: the board goes stale on Pete and says so
|
||||
}
|
||||
p.pushRoster()
|
||||
p.pushDetails()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +64,7 @@ func (p *AdventurePlugin) peteRosterTicker() {
|
||||
var rosterPushOK bool
|
||||
|
||||
func (p *AdventurePlugin) pushRoster() {
|
||||
snap, err := buildRosterSnapshot(time.Now().UTC())
|
||||
snap, err := buildRosterSnapshot(time.Now().UTC(), p.euro)
|
||||
if err != nil {
|
||||
slog.Error("roster: build snapshot failed", "err", err)
|
||||
return
|
||||
@@ -88,6 +92,376 @@ func (p *AdventurePlugin) pushRoster() {
|
||||
}
|
||||
}
|
||||
|
||||
// detailPushOK mirrors rosterPushOK for the private self-detail push: log the
|
||||
// transitions and nothing else.
|
||||
var detailPushOK bool
|
||||
|
||||
func (p *AdventurePlugin) pushDetails() {
|
||||
snap, err := p.buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
slog.Error("roster: build detail snapshot failed", "err", err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := peteclient.PushDetails(ctx, snap); err != nil {
|
||||
if detailPushOK {
|
||||
slog.Warn("roster: detail push failed, self-view will go stale on Pete", "err", err)
|
||||
} else {
|
||||
slog.Debug("roster: detail push failed, dropping snapshot", "err", err)
|
||||
}
|
||||
detailPushOK = false
|
||||
return
|
||||
}
|
||||
|
||||
if !detailPushOK {
|
||||
slog.Info("roster: private self-detail accepted by Pete", "players", len(snap.Players))
|
||||
detailPushOK = true
|
||||
}
|
||||
}
|
||||
|
||||
// rosterDetail assembles the public detail sheet for one adventurer: the combat
|
||||
// stats every visitor may see, plus equipped gear. Expedition context (supplies,
|
||||
// threat, room) is layered on by the caller when a run is active.
|
||||
func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail {
|
||||
d := &peteclient.RosterDetail{
|
||||
HPCurrent: c.HPCurrent,
|
||||
HPMax: c.HPMax,
|
||||
TempHP: c.TempHP,
|
||||
ArmorClass: c.ArmorClass,
|
||||
Abilities: [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA},
|
||||
Modifiers: c.Modifiers(),
|
||||
}
|
||||
if equip, err := loadAdvEquipment(uid); err == nil {
|
||||
for _, slot := range allSlots {
|
||||
e, ok := equip[slot]
|
||||
if !ok || e == nil {
|
||||
continue
|
||||
}
|
||||
d.Gear = append(d.Gear, peteclient.GearItem{
|
||||
Slot: string(slot),
|
||||
Name: e.Name,
|
||||
Tier: e.Tier,
|
||||
Condition: e.Condition,
|
||||
Masterwork: e.Masterwork,
|
||||
})
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// buildDetailSnapshot assembles the private, owner-only detail set — inventory,
|
||||
// vault, house, and pets for every alive player, keyed by localpart. It does NOT
|
||||
// skip opted-out players: the news opt-out governs the *public* board, but this
|
||||
// data is never shown there — Pete only ever serves it back to the one signed-in
|
||||
// owner it belongs to, so hiding a player from it would only deny them their own
|
||||
// sheet. The board token rides along so Pete can match owner↔page without ever
|
||||
// reversing the one-way token.
|
||||
func (p *AdventurePlugin) buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
||||
snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()}
|
||||
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
||||
if err != nil {
|
||||
return snap, err
|
||||
}
|
||||
var uids []id.UserID
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
rows.Close()
|
||||
return snap, err
|
||||
}
|
||||
uids = append(uids, id.UserID(uid))
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return snap, err
|
||||
}
|
||||
|
||||
for _, uid := range uids {
|
||||
lp := localpartOf(uid)
|
||||
if lp == "" {
|
||||
continue
|
||||
}
|
||||
adv, err := loadAdvCharacter(uid)
|
||||
if err != nil || adv == nil {
|
||||
continue
|
||||
}
|
||||
pd := peteclient.PlayerDetail{
|
||||
Localpart: lp,
|
||||
Token: eventToken(uid, "roster"),
|
||||
House: peteclient.HouseView{
|
||||
Tier: adv.HouseTier,
|
||||
LoanBalance: adv.HouseLoanBalance,
|
||||
Autopay: adv.HouseAutopay,
|
||||
Rate: adv.HouseCurrentRate,
|
||||
},
|
||||
Pets: petViews(adv),
|
||||
}
|
||||
if items, err := loadAdvInventory(uid); err == nil {
|
||||
pd.Inventory = itemViews(items)
|
||||
if equipped, err := loadEquippedMagicItems(uid); err == nil {
|
||||
attachInventoryCompares(pd.Inventory, items, equipped)
|
||||
}
|
||||
}
|
||||
if items, err := loadAdvVault(uid); err == nil {
|
||||
pd.Vault = itemViews(items)
|
||||
}
|
||||
pd.Equipped = equippedViews(uid)
|
||||
// Ask 7: the 5 standard slots for the web management panel, plus the euro
|
||||
// balance the upgrade/repair confirm dialogs show. Balance is nil-guarded so
|
||||
// the free-standing tests (which build no euro plugin) still run.
|
||||
pd.Slots = buildEquipSlotViews(uid)
|
||||
if p.euro != nil {
|
||||
pd.Balance = p.euro.GetBalance(uid)
|
||||
}
|
||||
snap.Players = append(snap.Players, pd)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// itemViews renders inventory or vault rows for the private panel, resolving
|
||||
// the display facts the row itself doesn't carry.
|
||||
//
|
||||
// Attuned is always false here and that is not an omission: equipping *moves*
|
||||
// the row out of adventure_inventory into magic_item_equipped, so nothing in a
|
||||
// backpack can hold a bond. Worn items come from equippedViews instead.
|
||||
func itemViews(items []AdvItem) []peteclient.ItemView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]peteclient.ItemView, 0, len(items))
|
||||
for _, it := range items {
|
||||
v := peteclient.ItemView{
|
||||
Name: it.Name,
|
||||
Type: it.Type,
|
||||
Tier: it.Tier,
|
||||
Value: it.Value,
|
||||
Temper: it.Temper,
|
||||
Slot: string(it.Slot),
|
||||
}
|
||||
// SkillSource is dual-use: a real skill name on masterwork gear, an
|
||||
// internal registry pointer on magic-item rows. Only the former is a
|
||||
// fact about the item; the latter is plumbing and stays home.
|
||||
if !strings.HasPrefix(it.SkillSource, "magic_item:") {
|
||||
v.SkillSource = it.SkillSource
|
||||
}
|
||||
if mi, ok := magicItemFromAdvItem(it); ok {
|
||||
eff := temperedItem(mi, it.Temper)
|
||||
v.Slot = string(eff.Slot)
|
||||
v.Desc = eff.Desc
|
||||
v.Effect = magicItemEffectSummary(eff)
|
||||
v.Attunement = eff.Attunement
|
||||
// The row id is the equip handle: a slotted magic item is the one thing
|
||||
// the web equip path can wear, so only it carries an id. Mundane gear (the
|
||||
// branch below) and unslotted curios get none, so no Equip button. This
|
||||
// runs for vault rows too — a vault magic item would carry an id — but Pete
|
||||
// offers the button on the backpack panel alone, so that's inert, not a leak.
|
||||
if eff.Slot != "" {
|
||||
v.ID = it.ID
|
||||
}
|
||||
} else if it.Slot != "" {
|
||||
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
||||
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
|
||||
// A masterwork/arena piece carries a real slot, so it can be worn from the
|
||||
// web (into a standard slot) — give it the equip handle. Plain shop gear in
|
||||
// the pack stays button-less: its slot is just a category, not a wearable.
|
||||
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
|
||||
v.ID = it.ID
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attachInventoryCompares fills the Compare card on every backpack magic item —
|
||||
// the ones that carry an equip id, which is exactly the set the web Equip button
|
||||
// acts on (equippedViews/vault rows are excluded by construction). views and items
|
||||
// are index-aligned: itemViews appends one view per item and skips none.
|
||||
func attachInventoryCompares(views []peteclient.ItemView, items []AdvItem, equipped map[DnDSlot]EquippedMagicItem) {
|
||||
for i := range views {
|
||||
if views[i].ID == 0 {
|
||||
continue // no equip id → mundane gear or unslotted curio → no button, no compare
|
||||
}
|
||||
mi, ok := magicItemFromAdvItem(items[i])
|
||||
if !ok || mi.Slot == "" {
|
||||
continue
|
||||
}
|
||||
views[i].Compare = magicItemCompare(mi, items[i].Temper, equipped)
|
||||
}
|
||||
}
|
||||
|
||||
// magicItemCompare pairs a candidate backpack item against whatever is worn in
|
||||
// the slot it would equip into (mi.Slot — the same slot applyMagicEquip targets,
|
||||
// so the card describes the trade the Equip button actually makes). The diff is
|
||||
// over *tempered* effects on both sides; bond availability decides the inert case.
|
||||
func magicItemCompare(cand MagicItem, temper int, equipped map[DnDSlot]EquippedMagicItem) *peteclient.ItemCompare {
|
||||
if cand.Slot == "" {
|
||||
return nil
|
||||
}
|
||||
candEff := magicItemEffectFor(temperedItem(cand, temper))
|
||||
|
||||
worn, wornExists := equipped[cand.Slot]
|
||||
if wornExists && worn.Item.ID == "" {
|
||||
wornExists = false // an empty EquippedMagicItem is not a real occupant
|
||||
}
|
||||
|
||||
// An empty slot compares against neutral: DamageReductMult is a multiplier, so
|
||||
// its neutral is 1.0, not the zero value (0 would read as -100% damage taken).
|
||||
wornEff := magicItemEffect{DamageReductMult: 1.0}
|
||||
vsName := ""
|
||||
if wornExists {
|
||||
wornEff = magicItemEffectFor(worn.Effective())
|
||||
vsName = worn.Effective().Name
|
||||
}
|
||||
deltas := magicItemDeltas(candEff, wornEff)
|
||||
|
||||
// Inert: the item wants a bond and none is free. Equipping evicts the slot's
|
||||
// occupant first, so an attuned occupant frees its own bond — count post-swap.
|
||||
inert := false
|
||||
if cand.Attunement {
|
||||
bonds := countAttunedMagicItems(equipped)
|
||||
if wornExists && worn.Attuned {
|
||||
bonds--
|
||||
}
|
||||
inert = bonds >= dndMagicItemAttuneLimit
|
||||
}
|
||||
|
||||
return &peteclient.ItemCompare{
|
||||
Verdict: compareVerdict(deltas, !wornExists, inert),
|
||||
VsName: vsName,
|
||||
VsSlot: string(cand.Slot),
|
||||
Deltas: deltas,
|
||||
}
|
||||
}
|
||||
|
||||
// compareVerdict classifies a set of deltas by strict dominance. Different stats
|
||||
// are not fungible — the engine can't say +3% damage beats -4 HP — so a mixed
|
||||
// result is a sidegrade with no winner claimed, which is the whole reason the
|
||||
// card exists. inert and new override the stat verdict.
|
||||
func compareVerdict(deltas []peteclient.ItemDelta, empty, inert bool) string {
|
||||
if inert {
|
||||
return "inert" // wearing it does nothing until a bond frees; stat diff is moot
|
||||
}
|
||||
if empty {
|
||||
return "new"
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
return "same"
|
||||
}
|
||||
gains, losses := 0, 0
|
||||
for _, d := range deltas {
|
||||
if d.Better {
|
||||
gains++
|
||||
} else {
|
||||
losses++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case losses == 0:
|
||||
return "upgrade"
|
||||
case gains == 0:
|
||||
return "downgrade"
|
||||
default:
|
||||
return "sidegrade"
|
||||
}
|
||||
}
|
||||
|
||||
// magicItemDeltas returns one entry per stat that visibly changes between the
|
||||
// candidate and the worn item. It diffs the structured effect fields, never the
|
||||
// summary string (which drops zero fields and would lose deltas). A change too
|
||||
// small to show at the rendered precision is omitted, so the verdict matches what
|
||||
// the player sees.
|
||||
func magicItemDeltas(cand, worn magicItemEffect) []peteclient.ItemDelta {
|
||||
var d []peteclient.ItemDelta
|
||||
|
||||
// DamageBonus / DamageReductMult are fractions rendered as whole percents.
|
||||
if pct := (cand.DamageBonus - worn.DamageBonus) * 100; roundedPct(pct) != 0 {
|
||||
d = append(d, peteclient.ItemDelta{Label: "damage", Better: pct > 0, Text: signedPct(pct, "damage")})
|
||||
}
|
||||
// DamageReductMult is a multiplier on damage TAKEN, so lower is better. Express
|
||||
// the change as damage taken: a positive number means you take more (worse).
|
||||
if taken := (cand.DamageReductMult - worn.DamageReductMult) * 100; roundedPct(taken) != 0 {
|
||||
d = append(d, peteclient.ItemDelta{Label: "defense", Better: taken < 0, Text: signedPct(taken, "damage taken")})
|
||||
}
|
||||
if diff := cand.FlatDmgStart - worn.FlatDmgStart; diff != 0 {
|
||||
d = append(d, peteclient.ItemDelta{Label: "opening", Better: diff > 0, Text: signedInt(diff, "opening damage")})
|
||||
}
|
||||
if diff := cand.MaxHP - worn.MaxHP; diff != 0 {
|
||||
d = append(d, peteclient.ItemDelta{Label: "hp", Better: diff > 0, Text: signedInt(diff, "HP")})
|
||||
}
|
||||
if cand.InitiativeBias != worn.InitiativeBias {
|
||||
faster := cand.InitiativeBias > worn.InitiativeBias
|
||||
text := "slower to act"
|
||||
if faster {
|
||||
text = "faster to act"
|
||||
}
|
||||
d = append(d, peteclient.ItemDelta{Label: "speed", Better: faster, Text: text})
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// roundedPct is the whole-percent a delta renders as; used to drop sub-percent
|
||||
// noise so the verdict never disagrees with the displayed chips.
|
||||
func roundedPct(v float64) int {
|
||||
if v < 0 {
|
||||
return int(v - 0.5)
|
||||
}
|
||||
return int(v + 0.5)
|
||||
}
|
||||
|
||||
func signedPct(v float64, noun string) string { return fmt.Sprintf("%+d%% %s", roundedPct(v), noun) }
|
||||
|
||||
func signedInt(v int, noun string) string { return fmt.Sprintf("%+d %s", v, noun) }
|
||||
|
||||
// equippedViews returns the magic items the player is actually wearing. This is
|
||||
// the only place Attuned means anything: the bond lives on the equipped row, and
|
||||
// with a cap of dndMagicItemAttuneLimit a worn item can be inert.
|
||||
func equippedViews(uid id.UserID) []peteclient.ItemView {
|
||||
equipped, err := loadEquippedMagicItems(uid)
|
||||
if err != nil || len(equipped) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]peteclient.ItemView, 0, len(equipped))
|
||||
for _, e := range equipped {
|
||||
eff := e.Effective()
|
||||
out = append(out, peteclient.ItemView{
|
||||
Name: eff.Name,
|
||||
Type: string(eff.Kind),
|
||||
Value: int64(eff.Value),
|
||||
Temper: e.Temper,
|
||||
Slot: string(e.Slot),
|
||||
Desc: eff.Desc,
|
||||
Effect: magicItemEffectSummary(eff),
|
||||
Attunement: eff.Attunement,
|
||||
Attuned: e.Attuned,
|
||||
})
|
||||
}
|
||||
// Map iteration is random; the panel must not reshuffle every 60s poll.
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
|
||||
return out
|
||||
}
|
||||
|
||||
// petViews returns the player's live pet slots. A pet that was chased away is
|
||||
// omitted — it isn't with them right now, and the self-view shows the present.
|
||||
func petViews(adv *AdventureCharacter) []peteclient.PetView {
|
||||
var out []peteclient.PetView
|
||||
if adv.PetType != "" && !adv.PetChasedAway {
|
||||
out = append(out, peteclient.PetView{
|
||||
Type: adv.PetType, Name: adv.PetName, Level: adv.PetLevel,
|
||||
XP: adv.PetXP, ArmorTier: adv.PetArmorTier,
|
||||
})
|
||||
}
|
||||
if adv.Pet2Type != "" && !adv.Pet2ChasedAway {
|
||||
out = append(out, peteclient.PetView{
|
||||
Type: adv.Pet2Type, Name: adv.Pet2Name, Level: adv.Pet2Level,
|
||||
XP: adv.Pet2XP, ArmorTier: adv.Pet2ArmorTier,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildRosterSnapshot assembles the complete board.
|
||||
//
|
||||
// Complete is the contract: Pete *replaces* its board with this, so anyone we
|
||||
@@ -96,8 +470,8 @@ func (p *AdventurePlugin) pushRoster() {
|
||||
// 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()}
|
||||
func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnapshot, error) {
|
||||
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix(), Tiers: mischiefTierCatalog()}
|
||||
|
||||
// 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*
|
||||
@@ -133,6 +507,22 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
|
||||
}
|
||||
|
||||
for _, pl := range players {
|
||||
// The buyer's own advisory balance rides along in a separate keyspace,
|
||||
// keyed by localpart (their sign-in name), and is collected *before* the
|
||||
// opt-out skip: opt-out hides a player from the public board, but their own
|
||||
// balance is private on Pete — only ever read for the user asking about
|
||||
// themselves — so there is no reason to deny an opted-out player the
|
||||
// storefront's affordability hint. localpart is lowercase, matching how the
|
||||
// buyer signs in and how a web order's username is resolved back to an MXID.
|
||||
if euro != nil {
|
||||
if lp := localpartOf(pl.uid); lp != "" {
|
||||
snap.Balances = append(snap.Balances, peteclient.MischiefBalance{
|
||||
Username: lp,
|
||||
Euro: euro.GetBalance(pl.uid),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if isNewsOptedOut(pl.uid) {
|
||||
continue
|
||||
}
|
||||
@@ -156,6 +546,8 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
|
||||
Status: "idle",
|
||||
}
|
||||
|
||||
e.Detail = rosterDetail(pl.uid, c)
|
||||
|
||||
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
e.Status = "expedition"
|
||||
@@ -166,6 +558,16 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
|
||||
e.Region = r.Name
|
||||
}
|
||||
}
|
||||
if e.Detail != nil {
|
||||
e.Detail.Supplies = int(exp.Supplies.Current)
|
||||
e.Detail.ThreatLevel = exp.ThreatLevel
|
||||
if exp.RunID != "" {
|
||||
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 {
|
||||
e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
|
||||
e.Detail.Map = buildRosterMap(run)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if pl.lastAction != nil {
|
||||
if h := int(now.Sub(*pl.lastAction).Hours()); h > 0 {
|
||||
e.IdleHours = h
|
||||
@@ -175,3 +577,112 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// buildRosterMap computes the fog-of-war cut of a run's zone graph for the
|
||||
// public roster. It sends every visited node with its true kind, plus the
|
||||
// one-hop frontier — the destinations of edges leading out of visited nodes,
|
||||
// with their kind withheld as "unknown". Edges are directed and stored by
|
||||
// from-node, so "one hop out of a visited node" is exactly g.Edges[visited].
|
||||
// A frontier node's edges are NOT walked, so nothing past the first closed
|
||||
// door reaches the wire — "view source to find the boss room" is not fog of
|
||||
// war. Node Label/Content never leave the game box.
|
||||
//
|
||||
// Output order is deterministic (visited-path order, then frontier in
|
||||
// discovery order) so an unchanged run produces a byte-identical snapshot and
|
||||
// the roster push does not churn.
|
||||
func buildRosterMap(run *DungeonRun) *peteclient.RosterMap {
|
||||
g, ok := loadZoneGraph(run.ZoneID)
|
||||
if !ok || len(run.VisitedNodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unique visited nodes in path order — VisitedNodes repeats on backtrack.
|
||||
orderedVisited := make([]string, 0, len(run.VisitedNodes))
|
||||
seen := make(map[string]bool, len(run.VisitedNodes))
|
||||
for _, id := range run.VisitedNodes {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
orderedVisited = append(orderedVisited, id)
|
||||
}
|
||||
}
|
||||
|
||||
m := &peteclient.RosterMap{
|
||||
ZoneID: string(run.ZoneID),
|
||||
CurrentNode: run.CurrentNode,
|
||||
Visited: orderedVisited,
|
||||
}
|
||||
|
||||
// Visited nodes first, in path order, with their real kind.
|
||||
emitted := make(map[string]bool, len(orderedVisited))
|
||||
for _, id := range orderedVisited {
|
||||
emitted[id] = true
|
||||
if n, ok := g.Nodes[id]; ok {
|
||||
m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: id, Kind: string(n.Kind)})
|
||||
}
|
||||
}
|
||||
|
||||
// Frontier: destinations of edges out of visited nodes, kind withheld.
|
||||
// Walk visited in path order so the frontier order is stable.
|
||||
for _, from := range orderedVisited {
|
||||
for _, edge := range g.Edges[from] {
|
||||
lock := edge.Lock
|
||||
if lock == LockNone {
|
||||
lock = "" // an open door needs no mark; omitempty drops it
|
||||
}
|
||||
m.Edges = append(m.Edges, peteclient.RosterMapEdge{
|
||||
From: edge.From,
|
||||
To: edge.To,
|
||||
Lock: string(lock),
|
||||
})
|
||||
if !seen[edge.To] && !emitted[edge.To] {
|
||||
emitted[edge.To] = true
|
||||
m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: edge.To, Kind: "unknown"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// resolveRosterToken maps a board token back to the adventurer it names. The
|
||||
// token is a one-way HMAC (eventToken), so it can't be inverted — instead we
|
||||
// recompute every live player's token and match. The salt is DB-persisted, so a
|
||||
// token minted before a restart still resolves after one. O(players) per call,
|
||||
// which is nothing at realm scale and only runs when a web order is being placed.
|
||||
func resolveRosterToken(token string) (id.UserID, bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
continue
|
||||
}
|
||||
if eventToken(id.UserID(uid), "roster") == token {
|
||||
return id.UserID(uid), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// mischiefTierCatalog is the storefront price list, pushed on every tick so the
|
||||
// web shop always renders gogobee's current prices — a fee retune here reaches
|
||||
// Pete within a snapshot, and Pete never hardcodes a number of its own. The
|
||||
// signed fee is the same +25% sink a Matrix buyer pays to put their name on it.
|
||||
func mischiefTierCatalog() []peteclient.MischiefTier {
|
||||
out := make([]peteclient.MischiefTier, 0, len(mischiefTiers))
|
||||
for _, t := range mischiefTiers {
|
||||
out = append(out, peteclient.MischiefTier{
|
||||
Key: t.Key,
|
||||
Display: t.Display,
|
||||
Fee: t.Fee,
|
||||
SignedFee: mischiefSignedFee(t.Fee),
|
||||
Blurb: t.Blurb,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
137
internal/plugin/pete_roster_map_test.go
Normal file
137
internal/plugin/pete_roster_map_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mapFixtureGraph registers a small branching graph and returns a cleanup.
|
||||
//
|
||||
// n1(entry) --none--------> n2(exploration) --key_required--> n4(boss)
|
||||
// \--perception_check--> n3(trap) ------------------------/
|
||||
//
|
||||
// n4 is reachable two ways; the validator wants a single entry and a reachable
|
||||
// boss, which this satisfies.
|
||||
func mapFixtureGraph(t *testing.T) ZoneID {
|
||||
t.Helper()
|
||||
const zid ZoneID = "map_test_zone"
|
||||
g := ZoneGraph{
|
||||
ZoneID: zid,
|
||||
Entry: "n1",
|
||||
Boss: "n4",
|
||||
Nodes: map[string]ZoneNode{
|
||||
"n1": {NodeID: "n1", ZoneID: zid, Kind: NodeKindEntry, IsEntry: true, Label: "The Gate"},
|
||||
"n2": {NodeID: "n2", ZoneID: zid, Kind: NodeKindExploration, Label: "Dusty Hall"},
|
||||
"n3": {NodeID: "n3", ZoneID: zid, Kind: NodeKindTrap, Label: "Spiked Pit"},
|
||||
"n4": {NodeID: "n4", ZoneID: zid, Kind: NodeKindBoss, IsBoss: true, Label: "Throne of Bone"},
|
||||
},
|
||||
Edges: map[string][]ZoneEdge{
|
||||
"n1": {
|
||||
{From: "n1", To: "n2", Lock: LockNone, Weight: 1},
|
||||
{From: "n1", To: "n3", Lock: LockPerception, Weight: 1},
|
||||
},
|
||||
"n2": {{From: "n2", To: "n4", Lock: LockKey, Weight: 1}},
|
||||
"n3": {{From: "n3", To: "n4", Lock: LockNone, Weight: 1}},
|
||||
},
|
||||
}
|
||||
registerZoneGraph(g)
|
||||
t.Cleanup(func() { delete(zoneGraphRegistry, zid) })
|
||||
return zid
|
||||
}
|
||||
|
||||
func TestBuildRosterMap_FogOfWar(t *testing.T) {
|
||||
zid := mapFixtureGraph(t)
|
||||
run := &DungeonRun{
|
||||
ZoneID: zid,
|
||||
CurrentNode: "n2",
|
||||
VisitedNodes: []string{"n1", "n2"},
|
||||
TotalRooms: 4,
|
||||
}
|
||||
m := buildRosterMap(run)
|
||||
if m == nil {
|
||||
t.Fatal("buildRosterMap returned nil for a visited run")
|
||||
}
|
||||
if m.ZoneID != string(zid) || m.CurrentNode != "n2" {
|
||||
t.Fatalf("header wrong: %+v", m)
|
||||
}
|
||||
|
||||
kinds := map[string]string{}
|
||||
for _, n := range m.Nodes {
|
||||
if _, dup := kinds[n.ID]; dup {
|
||||
t.Errorf("node %q emitted twice", n.ID)
|
||||
}
|
||||
kinds[n.ID] = n.Kind
|
||||
}
|
||||
|
||||
// Visited nodes carry their true kind.
|
||||
if kinds["n1"] != "entry" || kinds["n2"] != "exploration" {
|
||||
t.Errorf("visited kinds wrong: %v", kinds)
|
||||
}
|
||||
// Frontier nodes are present but their kind is withheld.
|
||||
if kinds["n3"] != "unknown" {
|
||||
t.Errorf("n3 is one hop out of n1 and must be unknown, got %q", kinds["n3"])
|
||||
}
|
||||
if kinds["n4"] != "unknown" {
|
||||
t.Errorf("n4 (the boss) is one hop out of n2 and must be unknown, got %q", kinds["n4"])
|
||||
}
|
||||
|
||||
// The map must never leak a room the player has not reached a door to.
|
||||
// n4 is a real boss node, but reachable only as frontier — its kind stays
|
||||
// hidden. A node past a frontier door (there is none deeper here) must not
|
||||
// appear at all; assert exactly four nodes.
|
||||
if len(m.Nodes) != 4 {
|
||||
t.Fatalf("want 4 nodes (2 visited + 2 frontier), got %d: %+v", len(m.Nodes), m.Nodes)
|
||||
}
|
||||
|
||||
// Edges out of visited nodes only. n3->n4 must NOT appear: n3 is frontier,
|
||||
// not visited, so walking its doors would leak structure past the fog.
|
||||
var edgeKeys []string
|
||||
for _, e := range m.Edges {
|
||||
edgeKeys = append(edgeKeys, e.From+"->"+e.To+":"+e.Lock)
|
||||
}
|
||||
want := map[string]bool{
|
||||
"n1->n2:": true, // LockNone dropped to ""
|
||||
"n1->n3:perception_check": true,
|
||||
"n2->n4:key_required": true,
|
||||
}
|
||||
if len(edgeKeys) != len(want) {
|
||||
t.Fatalf("want %d edges, got %v", len(want), edgeKeys)
|
||||
}
|
||||
for _, k := range edgeKeys {
|
||||
if !want[k] {
|
||||
t.Errorf("unexpected edge %q (n3->n4 would be a fog leak)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRosterMap_EmptyRunIsNil(t *testing.T) {
|
||||
zid := mapFixtureGraph(t)
|
||||
// A run that has visited nothing yet has no map to show.
|
||||
if m := buildRosterMap(&DungeonRun{ZoneID: zid}); m != nil {
|
||||
t.Errorf("empty VisitedNodes should yield nil, got %+v", m)
|
||||
}
|
||||
// An unknown zone (no graph, no legacy fallback row) yields nil, not a panic.
|
||||
if m := buildRosterMap(&DungeonRun{ZoneID: "no_such_zone", VisitedNodes: []string{"x"}}); m != nil {
|
||||
t.Errorf("unknown zone should yield nil, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRosterMap_BacktrackDedup(t *testing.T) {
|
||||
zid := mapFixtureGraph(t)
|
||||
// VisitedNodes is an ordered set that repeats on backtrack: n1,n2,n1.
|
||||
run := &DungeonRun{
|
||||
ZoneID: zid,
|
||||
CurrentNode: "n1",
|
||||
VisitedNodes: []string{"n1", "n2", "n1"},
|
||||
}
|
||||
m := buildRosterMap(run)
|
||||
seen := map[string]int{}
|
||||
for _, n := range m.Nodes {
|
||||
seen[n.ID]++
|
||||
}
|
||||
if seen["n1"] != 1 {
|
||||
t.Errorf("backtracked node n1 emitted %d times, want 1", seen["n1"])
|
||||
}
|
||||
if len(m.Visited) != 2 {
|
||||
t.Errorf("Visited should dedup to [n1 n2], got %v", m.Visited)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -49,7 +50,7 @@ func TestRosterSnapshotReadsTheClock(t *testing.T) {
|
||||
// Never acted at all: the fold falls through to created_at.
|
||||
seedRosterPlayer(t, "@never:test", "Camcast", &old, nil)
|
||||
|
||||
snap, err := buildRosterSnapshot(now)
|
||||
snap, err := buildRosterSnapshot(now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||
}
|
||||
@@ -94,7 +95,7 @@ func TestRosterOmitsOptedOut(t *testing.T) {
|
||||
seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old)
|
||||
setNewsOptout("@hidden:test", true)
|
||||
|
||||
snap, err := buildRosterSnapshot(now)
|
||||
snap, err := buildRosterSnapshot(now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||
}
|
||||
@@ -125,3 +126,101 @@ func TestRosterTokenIsNotAnEventToken(t *testing.T) {
|
||||
t.Error("board token not stable — the row would churn identity every snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// pickMagicItem returns a registry item matching want, so these tests read the
|
||||
// real registry rather than pinning an item ID that a later SRD dump could drop.
|
||||
func pickMagicItem(t *testing.T, want func(MagicItem) bool) MagicItem {
|
||||
t.Helper()
|
||||
var ids []string
|
||||
for id := range magicItemRegistry {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids) // map order is random; a flaky pick is a flaky test
|
||||
for _, id := range ids {
|
||||
if mi := magicItemRegistry[id]; want(mi) {
|
||||
return mi
|
||||
}
|
||||
}
|
||||
t.Skip("no registry item matches this shape")
|
||||
return MagicItem{}
|
||||
}
|
||||
|
||||
// TestItemViewsKeepsTheRegistryPointerHome is the leak guard. SkillSource is two
|
||||
// different things depending on the row: a player-facing skill name on
|
||||
// masterwork gear ("mining"), and the internal "magic_item:<id>" pointer that
|
||||
// resolves an inventory row back to the registry. Only the first is a fact about
|
||||
// the item. Sending the second would put gogobee's internal IDs on a page, and
|
||||
// Pete would have no way to tell them apart to filter them out.
|
||||
func TestItemViewsKeepsTheRegistryPointerHome(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Desc != "" && m.Slot != "" })
|
||||
|
||||
views := itemViews([]AdvItem{
|
||||
{Name: mi.Name, Type: "magic_item", Tier: 3, Value: 100,
|
||||
SkillSource: "magic_item:" + mi.ID},
|
||||
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
||||
Slot: SlotWeapon, SkillSource: "mining"},
|
||||
})
|
||||
|
||||
if views[0].SkillSource != "" {
|
||||
t.Errorf("the magic_item registry pointer went out on the wire: %q", views[0].SkillSource)
|
||||
}
|
||||
if views[0].Desc != mi.Desc {
|
||||
t.Errorf("desc = %q, want the registry's %q", views[0].Desc, mi.Desc)
|
||||
}
|
||||
if views[0].Effect == "" {
|
||||
t.Error("a magic item should carry the engine's own effect summary")
|
||||
}
|
||||
if views[1].SkillSource != "mining" {
|
||||
t.Errorf("masterwork skill source = %q, want it kept", views[1].SkillSource)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemViewsNeverClaimABackpackBond: equipping *moves* the row out of
|
||||
// adventure_inventory into magic_item_equipped, so nothing in a backpack can
|
||||
// hold a bond. Attuned must stay false here whatever the item wants, or the
|
||||
// panel tells a player an unworn item is working for them.
|
||||
func TestItemViewsNeverClaimABackpackBond(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" })
|
||||
|
||||
v := itemViews([]AdvItem{{Name: mi.Name, Type: "magic_item", Tier: 3,
|
||||
SkillSource: "magic_item:" + mi.ID}})[0]
|
||||
|
||||
if !v.Attunement {
|
||||
t.Error("an attunement item should say it wants a bond")
|
||||
}
|
||||
if v.Attuned {
|
||||
t.Error("a backpack item claimed a bond it cannot hold")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquippedViewsCarryBondState: the worn set is the only place Attuned means
|
||||
// anything, and the only way the page can show that a worn item is sitting inert
|
||||
// against the cap of three.
|
||||
func TestEquippedViewsCarryBondState(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
uid := id.UserID("@josie:example.org")
|
||||
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" })
|
||||
|
||||
if err := equipMagicItem(uid, mi.Slot, mi.ID, false, 0); err != nil {
|
||||
t.Fatalf("equip: %v", err)
|
||||
}
|
||||
views := equippedViews(uid)
|
||||
if len(views) != 1 {
|
||||
t.Fatalf("equipped views = %d, want 1", len(views))
|
||||
}
|
||||
if views[0].Attuned {
|
||||
t.Error("an inert worn item was reported as bonded")
|
||||
}
|
||||
if views[0].Slot != string(mi.Slot) {
|
||||
t.Errorf("slot = %q, want %q", views[0].Slot, mi.Slot)
|
||||
}
|
||||
|
||||
if err := equipMagicItem(uid, mi.Slot, mi.ID, true, 0); err != nil {
|
||||
t.Fatalf("re-equip bonded: %v", err)
|
||||
}
|
||||
if !equippedViews(uid)[0].Attuned {
|
||||
t.Error("a bonded worn item was reported as inert")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugin
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -193,6 +194,71 @@ func TestEmitZoneClearTaxonomy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTreasureFound: a story-grade find carries the item name and rarity,
|
||||
// and the realm's first finder of a given treasure is billed a PRIORITY hoard
|
||||
// while a later finder of the same item is a BULLETIN — the same first/repeat
|
||||
// split zone_first uses, but keyed on the treasure across the whole realm.
|
||||
func TestEmitTreasureFound(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 finder", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, "@zapp:x", "Zapp")
|
||||
db.Exec("seed second finder", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, "@kif:x", "Kif")
|
||||
|
||||
def := &AdvTreasureDef{Key: "thunderfury", Name: "Thunderfury, Blessed Blade of the Windseeker",
|
||||
Tier: 5, RoomAnnounce: "x got Thunderfury."}
|
||||
loc := &AdvLocation{Name: "The Abyssal Maw"}
|
||||
|
||||
emitTreasureFound(id.UserID("@zapp:x"), def, loc) // realm-first
|
||||
emitTreasureFound(id.UserID("@kif:x"), def, loc) // same item, later finder
|
||||
|
||||
if got := queuedCount(t, "treasure_found:%"); got != 2 {
|
||||
t.Fatalf("treasure_found queued = %d, want 2", got)
|
||||
}
|
||||
|
||||
// Pull both payloads and check the taxonomy split plus the carried fields.
|
||||
rows, err := db.Get().Query(`SELECT payload FROM pete_emit_queue WHERE guid LIKE 'treasure_found:%'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
tiers := map[string]int{}
|
||||
for rows.Next() {
|
||||
var payload string
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tiers[f["tier"].(string)]++
|
||||
if f["stakes"] != def.Name {
|
||||
t.Errorf("stakes = %v, want the item name", f["stakes"])
|
||||
}
|
||||
if f["zone"] != "The Abyssal Maw" {
|
||||
t.Errorf("zone = %v, want the location", f["zone"])
|
||||
}
|
||||
if f["outcome"] != "legendary" {
|
||||
t.Errorf("outcome = %v, want legendary for a tier-5 find", f["outcome"])
|
||||
}
|
||||
}
|
||||
if tiers["priority"] != 1 || tiers["bulletin"] != 1 {
|
||||
t.Errorf("tier split = %v, want one priority (realm-first) and one bulletin (repeat)", tiers)
|
||||
}
|
||||
|
||||
// The ledger is seeded, so a later live find of the same treasure won't
|
||||
// mis-announce as the first-ever.
|
||||
if claimRealmFirst("treasure", "thunderfury") {
|
||||
t.Error("realm-first ledger not seeded for the treasure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
|
||||
func TestNewsEmissionKillSwitch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
197
internal/plugin/postgame_bestiary.go
Normal file
197
internal/plugin/postgame_bestiary.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package plugin
|
||||
|
||||
// Tier 6 "Mythic" post-game bestiary (Phase P2) — 15 bespoke elites + 5
|
||||
// signature bosses. Layer 1 only: everything here is expressible with the
|
||||
// current engine (single MonsterAbility, HP/AC/Attack/BlockRate/FireAttacker).
|
||||
// The bespoke per-boss mechanics in gogobee_postgame_zones_plan.md are Layer 2
|
||||
// (later hooks) — the zones are shippable and fun on these stat blocks alone.
|
||||
//
|
||||
// These are opening bids, calibrated off post-D11 Belaxath (CR19, HP300,
|
||||
// AC20, Atk31). Every T6 boss opens noticeably above that; the P7 sim sweep
|
||||
// (n=750, L18/L20, control arm) decides the final numbers. IsElite is flagged
|
||||
// on the zone roster (ZoneEnemy), not here.
|
||||
//
|
||||
// Attack = average-damage convention; AttackBonus = d20 to-hit modifier.
|
||||
|
||||
var _ = func() bool {
|
||||
tier6 := map[string]DnDMonsterTemplate{
|
||||
// ── Zone 1 — The Ossuary Ascendant ──────────────────────────────
|
||||
"grave_cardinal": {
|
||||
ID: "grave_cardinal", Name: "Grave Cardinal",
|
||||
CR: 17, HP: 230, AC: 17, Attack: 28, AttackBonus: 10, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Last Rites", Phase: "any", ProcChance: 0.45, Effect: "debuff"},
|
||||
XPValue: 18000,
|
||||
Notes: "Ossuary elite. Fused clergy skeletons; censer of grave-smoke pre-blesses your corpse (accumulating AC drain).",
|
||||
},
|
||||
"reliquary_knight": {
|
||||
ID: "reliquary_knight", Name: "Reliquary Knight",
|
||||
CR: 18, HP: 230, AC: 20, Attack: 30, AttackBonus: 10, Speed: 10,
|
||||
BlockRate: 0.35,
|
||||
Ability: &MonsterAbility{Name: "Coffin Guard", Phase: "any", ProcChance: 0.50, Effect: "block"},
|
||||
XPValue: 20000,
|
||||
Notes: "Ossuary elite. Empty donor-bone armor; a coffin lid for a shield. The tanky wall of the tier.",
|
||||
},
|
||||
"the_chorister": {
|
||||
ID: "the_chorister", Name: "The Chorister",
|
||||
CR: 17, HP: 190, AC: 16, Attack: 27, AttackBonus: 10, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Dirge", Phase: "opening", ProcChance: 0.45, Effect: "stun"},
|
||||
XPValue: 18000,
|
||||
Notes: "Ossuary elite. Nine banshee throats in one column of veils; sings your own name in rounds.",
|
||||
},
|
||||
|
||||
// ── Zone 2 — The First Hoard ─────────────────────────────────────
|
||||
"coinborn_simulacrum": {
|
||||
ID: "coinborn_simulacrum", Name: "Coinborn Simulacrum",
|
||||
CR: 18, HP: 210, AC: 18, Attack: 22, AttackBonus: 10, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Mirror Mint", Phase: "opening", ProcChance: 0.50, Effect: "debuff"},
|
||||
XPValue: 20000,
|
||||
Notes: "First Hoard elite. The hoard's antibody: a person-shaped cascade of coins that mints a copy of your weapon. (Layer 2: Attack derived from player weapon bonus.)",
|
||||
},
|
||||
"wyrm_sworn_executor": {
|
||||
ID: "wyrm_sworn_executor", Name: "Wyrm-Sworn Executor",
|
||||
CR: 19, HP: 240, AC: 19, Attack: 24, AttackBonus: 11, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Scale Debt", Phase: "decisive", ProcChance: 0.30, Effect: "aoe_fire"},
|
||||
XPValue: 22000,
|
||||
Notes: "First Hoard elite. Dragonborn cultist end-state; half his body is ember and it is eating the rest.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
"choir_drake_matriarch": {
|
||||
ID: "choir_drake_matriarch", Name: "Choir Drake Matriarch",
|
||||
CR: 17, HP: 190, AC: 17, Attack: 21, AttackBonus: 10, Speed: 10,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Resonance", Phase: "any", ProcChance: 0.30, Effect: "stun"},
|
||||
XPValue: 18000,
|
||||
Notes: "First Hoard elite. A wingless drake that never left the cradle; resonance notes that crack stone.",
|
||||
},
|
||||
|
||||
// ── Zone 3 — The Unplace ─────────────────────────────────────────
|
||||
"the_unnumbered": {
|
||||
ID: "the_unnumbered", Name: "The Unnumbered",
|
||||
CR: 18, HP: 250, AC: 16, Attack: 18, AttackBonus: 10, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Too Many Arms", Phase: "any", ProcChance: 0.28, Effect: "aoe"},
|
||||
XPValue: 20000,
|
||||
Notes: "Unplace elite. Several demons in one silhouette; the count changes with the angle. (Layer 2: extra attack on rounds the player missed.)",
|
||||
},
|
||||
"angleworn_horror": {
|
||||
ID: "angleworn_horror", Name: "Angleworn Horror",
|
||||
CR: 17, HP: 180, AC: 18, Attack: 24, AttackBonus: 11, Speed: 14,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Oblique", Phase: "any", ProcChance: 0.50, Effect: "evade"},
|
||||
XPValue: 18000,
|
||||
Notes: "Unplace elite. Hunts through corners; has been attacking you since three rooms ago. (Layer 2: pre-applied damage tick on entry.)",
|
||||
},
|
||||
"echo_of_belaxath": {
|
||||
ID: "echo_of_belaxath", Name: "Echo of Belaxath",
|
||||
CR: 19, HP: 220, AC: 18, Attack: 23, AttackBonus: 11, Speed: 14,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Rerun", Phase: "decisive", ProcChance: 0.25, Effect: "aoe"},
|
||||
XPValue: 22000,
|
||||
Notes: "Unplace elite. A Balor outline filled with static; a memory the Unplace keeps replaying. Drops nothing — pure toll.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
|
||||
// ── Zone 4 — The Drowned Star ────────────────────────────────────
|
||||
"choir_of_static": {
|
||||
ID: "choir_of_static", Name: "Choir of Static",
|
||||
CR: 17, HP: 170, AC: 19, Attack: 28, AttackBonus: 10, Speed: 14,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Mimic Shoal", Phase: "any", ProcChance: 0.50, Effect: "evade"},
|
||||
XPValue: 18000,
|
||||
Notes: "Drowned Star elite. Thousands of hair-thin fish in the shape of the last thing that frightened them: you.",
|
||||
},
|
||||
"lantern_warden": {
|
||||
ID: "lantern_warden", Name: "Lantern Warden",
|
||||
CR: 18, HP: 230, AC: 19, Attack: 31, AttackBonus: 10, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "False Light", Phase: "opening", ProcChance: 0.45, Effect: "stun"},
|
||||
XPValue: 20000,
|
||||
Notes: "Drowned Star elite. An angler-knight; its lure is a stolen fragment of Seraphel's halo.",
|
||||
},
|
||||
"star_gorged_leviathanling": {
|
||||
ID: "star_gorged_leviathanling", Name: "Star-Gorged Leviathanling",
|
||||
CR: 19, HP: 290, AC: 16, Attack: 33, AttackBonus: 11, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Radiant Gout", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
|
||||
XPValue: 22000,
|
||||
Notes: "Drowned Star elite. Ate a piece of the star as a hatchling and never stopped glowing; its silhouette shows through its own flesh.",
|
||||
},
|
||||
|
||||
// ── Zone 5 — The Last Meridian ───────────────────────────────────
|
||||
"the_hour_thief": {
|
||||
ID: "the_hour_thief", Name: "The Hour Thief",
|
||||
CR: 17, HP: 180, AC: 18, Attack: 22, AttackBonus: 10, Speed: 18,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Pickpocket the Present", Phase: "any", ProcChance: 0.45, Effect: "stun"},
|
||||
XPValue: 18000,
|
||||
Notes: "Meridian elite. A stooped construct with a satchel full of ticking. (Layer 2: each proc permanently +2 its Speed this fight.)",
|
||||
},
|
||||
"pendulum_colossus": {
|
||||
ID: "pendulum_colossus", Name: "Pendulum Colossus",
|
||||
CR: 19, HP: 280, AC: 17, Attack: 30, AttackBonus: 11, Speed: 10,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Full Swing", Phase: "decisive", ProcChance: 0.50, Effect: "aoe"},
|
||||
XPValue: 22000,
|
||||
Notes: "Meridian elite. The cathedral's original pendulum, given legs; massive telegraphed hits. (Layer 2: only procs on even rounds — learnable.)",
|
||||
},
|
||||
"wardens_in_waiting": {
|
||||
ID: "wardens_in_waiting", Name: "Wardens-in-Waiting",
|
||||
CR: 18, HP: 260, AC: 19, Attack: 24, AttackBonus: 10, Speed: 10,
|
||||
BlockRate: 0.30,
|
||||
Ability: &MonsterAbility{Name: "Changing of the Guard", Phase: "any", ProcChance: 0.50, Effect: "block"},
|
||||
XPValue: 20000,
|
||||
Notes: "Meridian elite. Twin funerary constructs sharing one soul on a strict shift. (Layer 2: while one acts the other blocks — alternating BlockRate.)",
|
||||
},
|
||||
|
||||
// ── Signature bosses ─────────────────────────────────────────────
|
||||
"boss_valdris_ascendant": {
|
||||
ID: "boss_valdris_ascendant", Name: "Valdris, At Last",
|
||||
CR: 26, HP: 550, AC: 21, Attack: 52, AttackBonus: 12, Speed: 14,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Apotheosis Nova", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
|
||||
XPValue: 90000,
|
||||
Notes: "Ossuary Ascendant boss. A true lich, ascended one vertebra at a time. Phase 2 below 50% HP. (Layer 2: Phylactery Verses — three secret nodes each strip a Legendary Resistance / rebirth proc.)",
|
||||
},
|
||||
"boss_aurvandryx": {
|
||||
ID: "boss_aurvandryx", Name: "Aurvandryx, the Ember Before Fire",
|
||||
CR: 28, HP: 470, AC: 22, Attack: 45, AttackBonus: 12, Speed: 16,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "First Flame", Phase: "decisive", ProcChance: 0.45, Effect: "aoe_fire"},
|
||||
XPValue: 120000,
|
||||
Notes: "First Hoard boss. The progenitor wyrm whose shed scales became every dragon. Fire that predates fire resistance. Phase 2 below 40% HP. (Layer 2: Greed Tax — Attack scales with loot picked up this run.)",
|
||||
FireAttacker: true,
|
||||
},
|
||||
"boss_seamstress": {
|
||||
ID: "boss_seamstress", Name: "The Seamstress",
|
||||
CR: 27, HP: 460, AC: 21, Attack: 45, AttackBonus: 12, Speed: 14,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Needle Rain", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
|
||||
XPValue: 100000,
|
||||
Notes: "Unplace boss. A corrupted celestial sewing herself into the tear; half of her is on the other side. Phase 2 below 35% HP. (Layer 2: Inversion Stitch — healing and damage swap direction on her in telegraphed pulses.)",
|
||||
},
|
||||
"boss_seraphel": {
|
||||
ID: "boss_seraphel", Name: "Seraphel, the Light That Sank",
|
||||
CR: 27, HP: 560, AC: 21, Attack: 54, AttackBonus: 12, Speed: 14,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Sanctified Undertow", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
|
||||
XPValue: 100000,
|
||||
Notes: "Drowned Star boss. An angel who rode a dying star into the trench and kept it alive ten thousand years. Phase 2 below 50% HP. (Layer 2: Two Hearts — a second ~150 HP Star-Heart pool; mercy path is a hidden loot bias.)",
|
||||
},
|
||||
"boss_custodian": {
|
||||
ID: "boss_custodian", Name: "The Custodian of the Last Hour",
|
||||
CR: 27, HP: 500, AC: 21, Attack: 48, AttackBonus: 12, Speed: 14,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Chime", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
|
||||
XPValue: 100000,
|
||||
Notes: "Last Meridian boss. A titanic clock-golem decommissioning the hours; it apologizes before each swing. Phase 2 below 45% HP. (Layer 2: Amendment — rewinds to its round-3 HP snapshot once; soft midnight timer past round 20.)",
|
||||
},
|
||||
}
|
||||
for id, m := range tier6 {
|
||||
dndBestiary[id] = m
|
||||
}
|
||||
return true
|
||||
}()
|
||||
334
internal/plugin/postgame_boss_hooks.go
Normal file
334
internal/plugin/postgame_boss_hooks.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package plugin
|
||||
|
||||
// Tier-6 postgame "Layer-2" boss mechanics (Phase P8).
|
||||
//
|
||||
// Layer 1 (shipped) is everything the stock engine expresses: a single
|
||||
// MonsterAbility rider plus HP/AC/Attack/PhaseTwoAt on the stat block. Layer 2
|
||||
// is the bespoke, per-boss stuff the plan promised — mechanics that read the
|
||||
// *run* the player took to reach the boss, not just the fight in front of them.
|
||||
//
|
||||
// It holds BOTH halves of that seam:
|
||||
//
|
||||
// - PRE-COMBAT (applyBossRunModifiers / seedBossRunStatuses): adjustments
|
||||
// derived once from run state and folded into the boss's live Combatant (or
|
||||
// the fresh session) before the fight resolves. applyBossRunModifiers must be
|
||||
// a PURE, IDEMPOTENT function of run state, because the turn engine rebuilds
|
||||
// the enemy from the bestiary every single round (partyCombatantsForSession)
|
||||
// — the boss's numbers are re-derived on every !attack. That is fine as long
|
||||
// as the inputs are frozen for the fight, which they are: the boss room is
|
||||
// terminal, so no more nodes are walked once the fight begins.
|
||||
//
|
||||
// - IN-COMBAT (applyBossInCombatRoundEnd): round-boundary mechanics that read
|
||||
// and mutate the live combatState — HP snapshots, once-only rewinds, escalating
|
||||
// timers. Called from the turn engine's stepRoundEnd after the round's damage
|
||||
// has settled. Any state it spends round-trips through CombatStatuses so a
|
||||
// suspend/resume can't replay it.
|
||||
//
|
||||
// The remaining planned in-combat mechanics (Inversion Stitch, Two Hearts) are
|
||||
// still a separate seam — a new MonsterAbility.Effect case in applyAbility — and
|
||||
// are not in this file yet.
|
||||
|
||||
// applyBossRunModifiers folds any pre-combat Layer-2 mechanic for bossID into
|
||||
// the freshly-built enemy Combatant, reading the run the player took to get
|
||||
// here. It is dispatched by bestiary ID and is a no-op for every enemy that
|
||||
// isn't a hooked T6 boss, so the two build seams can call it unconditionally on
|
||||
// whatever they just built. A nil enemy or nil run is a no-op.
|
||||
func applyBossRunModifiers(bossID string, enemy *Combatant, run *DungeonRun) {
|
||||
if enemy == nil || run == nil {
|
||||
return
|
||||
}
|
||||
switch bossID {
|
||||
case "boss_aurvandryx":
|
||||
applyGreedTax(enemy, run)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Greed Tax — Aurvandryx, the Ember Before Fire (First Hoard) ─────────────
|
||||
//
|
||||
// The First Hoard carries the game's richest LootBias nodes on purpose (the
|
||||
// gilded veins at 2.5–3.0; nowhere else in the game exceeds 1.0). Aurvandryx —
|
||||
// the hoard's true owner, not its guard dog — takes the interest out of your
|
||||
// hide: her Attack rises with how much of the gilded route you walked to reach
|
||||
// her cradle. Strip the veins and meet a furious wyrm; take the vow-of-poverty
|
||||
// line through the zone and fight her lean.
|
||||
//
|
||||
// Signal: the summed excess LootBias (above the 1.0 baseline) of every node the
|
||||
// run walked — NOT run.LootCollected, which only tracks the boss-only signature
|
||||
// manifest and is empty at the boss. A full-explore route through the First
|
||||
// Hoard accrues ~3.5 richness (+7 Attack); the cap covers a maximal line.
|
||||
//
|
||||
// Calibration (P8 sim sweep, L20 party+Pete+pets): the full-explore route lands
|
||||
// first_hoard at ~47% clear (mid-band 40–55), down from the taxless 56%. A lean
|
||||
// prod route pays no tax and fights her at the taxless rate; a maximal route
|
||||
// hits the cap. No base-stat retune was needed — the tax corrects the prior
|
||||
// slight overshoot into the band. NOTE: the sim's autopilot explores the whole
|
||||
// graph, so the sweep only exercises the full-route point; the lean/greedy
|
||||
// spread is a prod-only player-agency lever the headless sim cannot walk.
|
||||
const (
|
||||
// greedTaxRichnessMult converts summed route richness into Attack points.
|
||||
greedTaxRichnessMult = 2.0
|
||||
|
||||
// greedTaxMaxAttack caps the tax so a maximal hoard-run meets a very hard
|
||||
// wyrm, never a literally-unbeatable one.
|
||||
greedTaxMaxAttack = 12
|
||||
)
|
||||
|
||||
// greedRouteRichness sums the excess LootBias (above the 1.0 baseline) of every
|
||||
// node the run has walked. Extracted for a deterministic unit test.
|
||||
func greedRouteRichness(run *DungeonRun) float64 {
|
||||
g, ok := loadZoneGraph(run.ZoneID)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
var rich float64
|
||||
for _, id := range run.VisitedNodes {
|
||||
if n, exists := g.Nodes[id]; exists && n.Content.LootBias > 1.0 {
|
||||
rich += n.Content.LootBias - 1.0
|
||||
}
|
||||
}
|
||||
return rich
|
||||
}
|
||||
|
||||
// greedTaxAttack is the Attack surcharge for a route of the given richness.
|
||||
func greedTaxAttack(richness float64) int {
|
||||
tax := int(richness * greedTaxRichnessMult)
|
||||
if tax > greedTaxMaxAttack {
|
||||
tax = greedTaxMaxAttack
|
||||
}
|
||||
if tax < 0 {
|
||||
tax = 0
|
||||
}
|
||||
return tax
|
||||
}
|
||||
|
||||
func applyGreedTax(enemy *Combatant, run *DungeonRun) {
|
||||
enemy.Stats.Attack += greedTaxAttack(greedRouteRichness(run))
|
||||
}
|
||||
|
||||
// ── Phylactery Verses — Valdris, At Last (The Ossuary Ascendant) ────────────
|
||||
//
|
||||
// The plan Valdris has been running since he "died" in the T1 Crypt: the
|
||||
// phylactery shard players looted for years was bait, and he has rebuilt as a
|
||||
// true lich. His rebirths are bound into three Verses hidden in the cathedral,
|
||||
// each a NodeKindSecret behind a Perception gate. Every Verse a player finds and
|
||||
// walks before the fight UNBINDS one rebirth; every Verse they skip leaves it
|
||||
// armed. Full-clear explorers strip all three and fight a mortal lich;
|
||||
// speedrunners who blow past the secrets fight a god who will not stay down.
|
||||
//
|
||||
// This is the mirror-image of the Greed Tax's design axis: the Tax punishes
|
||||
// greedy exploration, the Verses reward thorough exploration. Both are prod
|
||||
// player-agency levers the headless sim only samples at whatever route its
|
||||
// autopilot happens to walk.
|
||||
//
|
||||
// Unlike the Greed Tax (a pure per-round Attack recompute), a rebirth is spent
|
||||
// mid-fight, so it is stateful: seedBossRunStatuses freezes the charge count
|
||||
// onto the session ONCE at fight start, and the turn engine round-trips the
|
||||
// live count through CombatStatuses. It must NOT be re-derived on the per-round
|
||||
// enemy rebuild, or spent rebirths would come back every round.
|
||||
const (
|
||||
// phylacteryReviveFrac is the fraction of the boss's (party-scaled) max HP
|
||||
// each unbound rebirth restores him to — a real second wind, not the 1-HP
|
||||
// stay of survive_at_1.
|
||||
phylacteryReviveFrac = 4 // 1/4 == 25%
|
||||
)
|
||||
|
||||
// phylacteryReviveCharges is the number of rebirths still armed on Valdris: one
|
||||
// per zone Verse (NodeKindSecret) the run has NOT visited. The Ossuary's only
|
||||
// secret nodes are the three Verses (the shared builder stamps none by default),
|
||||
// so counting unvisited secrets is exactly "unbound rebirths". Zero for any
|
||||
// non-Valdris boss or a nil run. A pure function of frozen run state.
|
||||
func phylacteryReviveCharges(bossID string, run *DungeonRun) int {
|
||||
if bossID != "boss_valdris_ascendant" || run == nil {
|
||||
return 0
|
||||
}
|
||||
g, ok := loadZoneGraph(run.ZoneID)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
visited := make(map[string]bool, len(run.VisitedNodes))
|
||||
for _, id := range run.VisitedNodes {
|
||||
visited[id] = true
|
||||
}
|
||||
charges := 0
|
||||
for id, n := range g.Nodes {
|
||||
if n.Kind == NodeKindSecret && !visited[id] {
|
||||
charges++
|
||||
}
|
||||
}
|
||||
return charges
|
||||
}
|
||||
|
||||
// seedBossRunStatuses folds any ONCE-AT-FIGHT-START Layer-2 boss state into the
|
||||
// freshly-created session, reading the run the player took to get here. It is
|
||||
// the stateful counterpart to applyBossRunModifiers (which is a per-round pure
|
||||
// recompute): whatever it seeds here is mutated by the fight and round-tripped,
|
||||
// never re-derived. enemyMaxHP is the party-scaled pool already persisted onto
|
||||
// the session. Returns whether it changed anything (so the caller can skip a
|
||||
// redundant save). No-op — false — for every non-hooked boss.
|
||||
func seedBossRunStatuses(sess *CombatSession, bossID string, enemyMaxHP int, run *DungeonRun) bool {
|
||||
if sess == nil {
|
||||
return false
|
||||
}
|
||||
if charges := phylacteryReviveCharges(bossID, run); charges > 0 {
|
||||
sess.Statuses.EnemyReviveCharges = charges
|
||||
sess.Statuses.EnemyReviveHP = max(1, enemyMaxHP/phylacteryReviveFrac)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Amendment — The Custodian of the Last Hour (The Last Meridian) ───────────
|
||||
//
|
||||
// The Custodian has concluded its contract is complete and is dismantling the
|
||||
// hours on its way out — including, once, the last few minutes of its own fight.
|
||||
// Its true durability is HIDDEN in the opening rounds: it snapshots its HP at the
|
||||
// end of round 3, and the first time the party knocks it into phase 2 it *rewinds
|
||||
// itself* to that snapshot — once. Front-loaded burst is partially refunded (the
|
||||
// damage past the snapshot is undone), so sustained builds that can grind through
|
||||
// the extra pool shine over glass-cannon alpha strikes. A soft "midnight timer"
|
||||
// then leans on stalls: every round past round 20 the Custodian's Attack climbs,
|
||||
// so a fight that can't close still resolves before the clock runs out.
|
||||
//
|
||||
// This is the first IN-COMBAT Layer-2 mechanic. Unlike the pre-combat hooks it
|
||||
// reads/mutates the live combatState at round end, and its once-only state
|
||||
// (EnemyRewindHP snapshot + EnemyRewindUsed) round-trips through CombatStatuses so
|
||||
// a suspend/resume can't hand the boss a second rewind. Dispatched by bestiary ID,
|
||||
// so it is a no-op for every other enemy.
|
||||
const (
|
||||
// custodianSnapshotRound is the round whose end HP the Custodian rewinds to.
|
||||
// Snapshotting late enough that a normal party has committed real damage, but
|
||||
// early enough that the refund still matters, is the whole point — the fight's
|
||||
// true length is concealed until the rewind fires.
|
||||
custodianSnapshotRound = 3
|
||||
|
||||
// custodianPhaseTwoFrac must track The Custodian's PhaseTwoAt in
|
||||
// postgame_zone_defs.go (0.45): the rewind is meant to fire exactly as the
|
||||
// party crosses the boss into phase 2. Kept as a local constant because the
|
||||
// turn engine resolves off the bestiary template + session, not the
|
||||
// ZoneDefinition, so the threshold isn't otherwise in reach at round end.
|
||||
custodianPhaseTwoFrac = 0.45
|
||||
|
||||
// midnightAtkStep / midnightAtkCap: the soft closing-time timer. Every round
|
||||
// past midnightTimerAfter adds midnightAtkStep to the boss's Attack (via the
|
||||
// shared EnemyAtkBuff, which enemyAttackStat already folds in), capped so a
|
||||
// truly stalled fight still ends without the number becoming meaningless.
|
||||
midnightTimerAfter = 20
|
||||
midnightAtkStep = 2
|
||||
midnightAtkCap = 40
|
||||
)
|
||||
|
||||
// applyBossInCombatRoundEnd resolves the round-boundary in-combat Layer-2
|
||||
// mechanics for the enemy the turn engine is fighting. It runs after the round's
|
||||
// damage has settled and only while the enemy still stands. Dispatched by
|
||||
// bestiary ID; a no-op for every enemy that isn't a hooked T6 boss.
|
||||
func applyBossInCombatRoundEnd(st *combatState, bossID string, enemyMaxHP int) {
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
switch bossID {
|
||||
case "boss_custodian":
|
||||
applyAmendment(st, enemyMaxHP)
|
||||
case "boss_seamstress":
|
||||
applyInversionStitch(st, enemyMaxHP)
|
||||
}
|
||||
}
|
||||
|
||||
// applyAmendment resolves the Custodian's Amendment (round-3 snapshot + once-only
|
||||
// phase-2 rewind) and its soft midnight timer, given the round that just finished
|
||||
// (st.round, pre-increment) and the boss's party-scaled MaxHP.
|
||||
func applyAmendment(st *combatState, enemyMaxHP int) {
|
||||
// Snapshot the boss's HP at the end of round 3 — the concealed "true length".
|
||||
if st.round == custodianSnapshotRound && st.enemyRewindHP == 0 {
|
||||
st.enemyRewindHP = st.enemyHP
|
||||
}
|
||||
// The first time the party crosses the boss into phase 2, rewind to the
|
||||
// snapshot — once. Guarded on snapshot > current so a party that never got
|
||||
// below the round-3 HP (and a fight bursted into phase 2 before the snapshot
|
||||
// was even taken, EnemyRewindHP == 0) can't be handed a free heal.
|
||||
phaseTwo := int(custodianPhaseTwoFrac * float64(enemyMaxHP))
|
||||
if !st.enemyRewindUsed && st.enemyRewindHP > st.enemyHP && st.enemyHP <= phaseTwo {
|
||||
st.enemyHP = min(enemyMaxHP, st.enemyRewindHP)
|
||||
st.enemyRewindUsed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "amendment_rewind",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
// Soft midnight timer: closing time leans on a stalled fight.
|
||||
if st.round >= midnightTimerAfter && st.enemyAtkBuff < midnightAtkCap {
|
||||
st.enemyAtkBuff = min(midnightAtkCap, st.enemyAtkBuff+midnightAtkStep)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "midnight_toll",
|
||||
Damage: midnightAtkStep, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inversion Stitch — The Seamstress (The Unplace) ──────────────────────────
|
||||
//
|
||||
// The Seamstress is sewing the tear shut from the inside, and once she's far
|
||||
// enough into the work (phase 2) she starts turning the room inside-out around
|
||||
// the party: for a two-round pulse, healing runs the wrong direction — every
|
||||
// cure lands as a wound (gated in stepPlayerActionEffect, floored at 1 HP so a
|
||||
// player is never killed by their own healer, only softened for the Seamstress's
|
||||
// own blows to finish). The catch is that she can't invert the room without a
|
||||
// visible tell: each pulse is telegraphed one full round ahead, so a player who
|
||||
// reads it holds their heals and waits it out. The autopilot that drives the sim
|
||||
// does not read tells — so the headless sweep sees the mechanic at its harshest,
|
||||
// the way an undisciplined party would, and a real player who respects the
|
||||
// warning fares better. That gap IS the difficulty lever, the same way the Greed
|
||||
// Tax and the Phylactery Verses are prod-only player-agency levers.
|
||||
//
|
||||
// It is the second in-combat Layer-2 mechanic, so it rides the same round-end
|
||||
// seam Amendment opened. Its state (inversionActive countdown + inversionTelegraph
|
||||
// warning) round-trips through CombatStatuses so a suspend/resume can't drop or
|
||||
// double a pulse. A no-op outside the Seamstress's phase 2.
|
||||
const (
|
||||
// seamstressPhaseTwoFrac must track The Seamstress's PhaseTwoAt in
|
||||
// postgame_zone_defs.go (0.35): the inversion only starts once she is sewn
|
||||
// deep enough into the tear. Kept local for the same reason as
|
||||
// custodianPhaseTwoFrac — the round-end hook resolves off the session, not the
|
||||
// ZoneDefinition, so the threshold isn't otherwise in reach here.
|
||||
seamstressPhaseTwoFrac = 0.35
|
||||
|
||||
// inversionPulseRounds is how many rounds each inside-out pulse lasts once it
|
||||
// activates (heals sting for this many player-rounds).
|
||||
inversionPulseRounds = 2
|
||||
)
|
||||
|
||||
// applyInversionStitch drives the Seamstress's phase-2 inversion cadence at round
|
||||
// end: telegraph a pulse one round ahead, activate it for inversionPulseRounds,
|
||||
// then re-telegraph the next — giving a repeating warn(1) → sting(2) rhythm the
|
||||
// player can play around. No-op until the boss is in phase 2.
|
||||
func applyInversionStitch(st *combatState, enemyMaxHP int) {
|
||||
// Layer-1 fight until the Seamstress is sewn into her own phase 2.
|
||||
phaseTwo := int(seamstressPhaseTwoFrac * float64(enemyMaxHP))
|
||||
if st.enemyHP > phaseTwo {
|
||||
return
|
||||
}
|
||||
// An active pulse counts down one round per round end. While it stays above
|
||||
// zero the next round's heals still sting; when it lapses this round, fall
|
||||
// through so a fresh telegraph is scheduled immediately.
|
||||
if st.inversionActive > 0 {
|
||||
st.inversionActive--
|
||||
if st.inversionActive > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
// A telegraphed pulse activates: the room turns inside-out for the pulse.
|
||||
if st.inversionTelegraph {
|
||||
st.inversionActive = inversionPulseRounds
|
||||
st.inversionTelegraph = false
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_stitch",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Otherwise warn: the next round's pulse is one round out.
|
||||
st.inversionTelegraph = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_telegraph",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
534
internal/plugin/postgame_boss_hooks_test.go
Normal file
534
internal/plugin/postgame_boss_hooks_test.go
Normal file
@@ -0,0 +1,534 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGreedTaxAttack(t *testing.T) {
|
||||
cases := []struct {
|
||||
rich float64
|
||||
want int
|
||||
}{
|
||||
{rich: -1, want: 0},
|
||||
{rich: 0, want: 0},
|
||||
{rich: 0.4, want: 0}, // 0.4*2 = 0.8, floors to 0
|
||||
{rich: 0.5, want: 1}, // one point per 0.5 richness
|
||||
{rich: 3.5, want: 7}, // the sim's full-explore route
|
||||
{rich: 5.25, want: 10},
|
||||
{rich: 6, want: 12}, // exactly the cap
|
||||
{rich: 100, want: 12}, // capped
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := greedTaxAttack(c.rich); got != c.want {
|
||||
t.Errorf("greedTaxAttack(%.2f) = %d, want %d", c.rich, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreedRouteRichness(t *testing.T) {
|
||||
// The First Hoard's gilded veins are the only >1.0 LootBias nodes in the
|
||||
// game: cap12@3.0, f1b2@2.75, r2b2@2.5 (see zone_graph_first_hoard.go). The
|
||||
// graph builder prefixes every node id with the zone id + ".".
|
||||
const pre = "first_hoard."
|
||||
|
||||
// The full gilded route sums every vein's excess bias.
|
||||
all := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "cap12", pre + "f1b2", pre + "r2b2", pre + "boss"}}
|
||||
if got := greedRouteRichness(all); !approxEq(got, 5.25) {
|
||||
t.Errorf("full route richness = %.2f, want 5.25", got)
|
||||
}
|
||||
|
||||
// A single vein contributes only its own excess.
|
||||
one := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "cap12", pre + "boss"}}
|
||||
if got := greedRouteRichness(one); !approxEq(got, 2.0) {
|
||||
t.Errorf("one-vein (cap12@3.0) richness = %.2f, want 2.0", got)
|
||||
}
|
||||
|
||||
// A lean line that never touches a vein pays nothing.
|
||||
lean := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "p1", pre + "p2", pre + "boss"}}
|
||||
if got := greedRouteRichness(lean); !approxEq(got, 0) {
|
||||
t.Errorf("lean route richness = %.2f, want 0", got)
|
||||
}
|
||||
|
||||
// An unknown zone has no graph and no richness.
|
||||
if got := greedRouteRichness(&DungeonRun{ZoneID: "nope", VisitedNodes: []string{"x"}}); got != 0 {
|
||||
t.Errorf("unknown-zone richness = %.2f, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBossRunModifiers_GreedTax(t *testing.T) {
|
||||
richRun := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{"first_hoard.cap12", "first_hoard.f1b2", "first_hoard.r2b2"}} // 5.25 rich → +10 Attack
|
||||
leanRun := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{"first_hoard.entry", "first_hoard.boss"}} // 0 rich → +0
|
||||
|
||||
// Aurvandryx's Attack rises with the gilded route walked into her cradle.
|
||||
enemy := &Combatant{Stats: CombatStats{Attack: 45}}
|
||||
applyBossRunModifiers("boss_aurvandryx", enemy, richRun)
|
||||
if enemy.Stats.Attack != 45+10 {
|
||||
t.Errorf("greedy-route Aurvandryx Attack = %d, want %d", enemy.Stats.Attack, 45+10)
|
||||
}
|
||||
|
||||
// A lean run leaves her at her base Attack.
|
||||
lean := &Combatant{Stats: CombatStats{Attack: 45}}
|
||||
applyBossRunModifiers("boss_aurvandryx", lean, leanRun)
|
||||
if lean.Stats.Attack != 45 {
|
||||
t.Errorf("lean-run Aurvandryx Attack = %d, want 45", lean.Stats.Attack)
|
||||
}
|
||||
|
||||
// Every non-hooked enemy is untouched, even on the richest route.
|
||||
other := &Combatant{Stats: CombatStats{Attack: 45}}
|
||||
applyBossRunModifiers("boss_seamstress", other, richRun)
|
||||
if other.Stats.Attack != 45 {
|
||||
t.Errorf("non-hooked boss Attack = %d, want 45 (no-op)", other.Stats.Attack)
|
||||
}
|
||||
|
||||
// A nil run is a no-op, not a panic.
|
||||
nilRun := &Combatant{Stats: CombatStats{Attack: 45}}
|
||||
applyBossRunModifiers("boss_aurvandryx", nilRun, nil)
|
||||
if nilRun.Stats.Attack != 45 {
|
||||
t.Errorf("nil-run Attack = %d, want 45 (no-op)", nilRun.Stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhylacteryReviveCharges(t *testing.T) {
|
||||
// The Ossuary's three Verses are its only secret nodes: f1b2, f2b2, cap32
|
||||
// (see zone_graph_ossuary_ascendant.go). Node ids are zone-prefixed.
|
||||
const pre = "ossuary_ascendant."
|
||||
|
||||
// Skip every Verse → all three rebirths stay armed (fight a god).
|
||||
skip := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "p1", pre + "boss"}}
|
||||
if got := phylacteryReviveCharges("boss_valdris_ascendant", skip); got != 3 {
|
||||
t.Errorf("skip-route charges = %d, want 3", got)
|
||||
}
|
||||
|
||||
// Find one Verse → one rebirth unbound, two remain.
|
||||
one := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "f1b2", pre + "boss"}}
|
||||
if got := phylacteryReviveCharges("boss_valdris_ascendant", one); got != 2 {
|
||||
t.Errorf("one-Verse charges = %d, want 2", got)
|
||||
}
|
||||
|
||||
// Full-clear explorer finds all three → 0 rebirths, a mortal lich.
|
||||
full := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "f1b2", pre + "f2b2", pre + "cap32", pre + "boss"}}
|
||||
if got := phylacteryReviveCharges("boss_valdris_ascendant", full); got != 0 {
|
||||
t.Errorf("full-clear charges = %d, want 0", got)
|
||||
}
|
||||
|
||||
// Only Valdris carries the Verses; a nil run and any other boss are 0.
|
||||
if got := phylacteryReviveCharges("boss_aurvandryx", skip); got != 0 {
|
||||
t.Errorf("non-Valdris boss charges = %d, want 0", got)
|
||||
}
|
||||
if got := phylacteryReviveCharges("boss_valdris_ascendant", nil); got != 0 {
|
||||
t.Errorf("nil-run charges = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedBossRunStatuses_Phylactery(t *testing.T) {
|
||||
const pre = "ossuary_ascendant."
|
||||
skip := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "boss"}} // 3 unfound
|
||||
|
||||
// A skip-route seeds all three rebirths and the 25%-of-max revive pool.
|
||||
sess := &CombatSession{}
|
||||
if !seedBossRunStatuses(sess, "boss_valdris_ascendant", 560, skip) {
|
||||
t.Fatal("skip-route seed returned false, want true (charges seeded)")
|
||||
}
|
||||
if sess.Statuses.EnemyReviveCharges != 3 {
|
||||
t.Errorf("seeded charges = %d, want 3", sess.Statuses.EnemyReviveCharges)
|
||||
}
|
||||
if sess.Statuses.EnemyReviveHP != 140 { // 560/4
|
||||
t.Errorf("seeded revive HP = %d, want 140", sess.Statuses.EnemyReviveHP)
|
||||
}
|
||||
|
||||
// A full-clear seeds nothing and reports no change.
|
||||
full := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "f1b2", pre + "f2b2", pre + "cap32"}}
|
||||
clean := &CombatSession{}
|
||||
if seedBossRunStatuses(clean, "boss_valdris_ascendant", 560, full) {
|
||||
t.Error("full-clear seed returned true, want false (no rebirths)")
|
||||
}
|
||||
if clean.Statuses.EnemyReviveCharges != 0 {
|
||||
t.Errorf("full-clear seeded charges = %d, want 0", clean.Statuses.EnemyReviveCharges)
|
||||
}
|
||||
|
||||
// Non-hooked boss: no-op even on a skip route.
|
||||
other := &CombatSession{}
|
||||
if seedBossRunStatuses(other, "boss_seamstress", 560, skip) {
|
||||
t.Error("non-hooked boss seed returned true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnemyDown_PhylacteryRebirth exercises the engine primitive: each seeded
|
||||
// charge revives the boss to the revive pool once, and the fight ends only when
|
||||
// the last charge is spent.
|
||||
func TestEnemyDown_PhylacteryRebirth(t *testing.T) {
|
||||
player := &Combatant{Name: "Hero", IsPlayer: true, Stats: CombatStats{MaxHP: 100, AC: 15, Attack: 10}}
|
||||
seat0 := newActor(player)
|
||||
st := &combatState{actor: seat0, actors: []*actor{seat0}, enemyReviveCharges: 2, enemyReviveHP: 140}
|
||||
|
||||
// First lethal blow: a charge spends, boss revives to the pool, not down.
|
||||
st.enemyHP = 0
|
||||
if enemyDown(st, "test") {
|
||||
t.Fatal("first killing blow reported down, want rebirth")
|
||||
}
|
||||
if st.enemyHP != 140 || st.enemyReviveCharges != 1 {
|
||||
t.Errorf("after 1st rebirth: HP=%d charges=%d, want 140/1", st.enemyHP, st.enemyReviveCharges)
|
||||
}
|
||||
|
||||
// Second lethal blow: last charge spends, revives again.
|
||||
st.enemyHP = -5
|
||||
if enemyDown(st, "test") {
|
||||
t.Fatal("second killing blow reported down, want rebirth")
|
||||
}
|
||||
if st.enemyHP != 140 || st.enemyReviveCharges != 0 {
|
||||
t.Errorf("after 2nd rebirth: HP=%d charges=%d, want 140/0", st.enemyHP, st.enemyReviveCharges)
|
||||
}
|
||||
|
||||
// Third lethal blow: no charges left, the lich stays dead.
|
||||
st.enemyHP = 0
|
||||
if !enemyDown(st, "test") {
|
||||
t.Error("charge-less killing blow reported alive, want down")
|
||||
}
|
||||
|
||||
// A rebirth event was emitted per revival (two), for the narrator.
|
||||
rebirths := 0
|
||||
for _, e := range st.events {
|
||||
if e.Action == "phylactery_rebirth" {
|
||||
rebirths++
|
||||
}
|
||||
}
|
||||
if rebirths != 2 {
|
||||
t.Errorf("phylactery_rebirth events = %d, want 2", rebirths)
|
||||
}
|
||||
}
|
||||
|
||||
func approxEq(a, b float64) bool {
|
||||
d := a - b
|
||||
return d < 1e-9 && d > -1e-9
|
||||
}
|
||||
|
||||
// custodianState builds a live combatState seated against a Custodian-shaped
|
||||
// enemy (MaxHP given), at the given round and current enemy HP, for direct
|
||||
// applyAmendment tests. Uses the real turn engine so the embedded actor/roster
|
||||
// are fully formed.
|
||||
func custodianState(t *testing.T, round, enemyHP, enemyMaxHP int) *combatState {
|
||||
t.Helper()
|
||||
sess := turnSession(CombatPhaseRoundEnd, 10000, enemyHP)
|
||||
sess.Round = round
|
||||
sess.EnemyID = "boss_custodian"
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = enemyMaxHP
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.enemyHP = enemyHP
|
||||
return te.st
|
||||
}
|
||||
|
||||
func countEvents(events []CombatEvent, action string) int {
|
||||
n := 0
|
||||
for _, ev := range events {
|
||||
if ev.Action == action {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestApplyAmendment_SnapshotAtRoundThree(t *testing.T) {
|
||||
// Before round 3: no snapshot.
|
||||
st := custodianState(t, 2, 400, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyRewindHP != 0 {
|
||||
t.Errorf("round 2 snapshot = %d, want 0 (not yet)", st.enemyRewindHP)
|
||||
}
|
||||
// End of round 3: snapshot the current HP.
|
||||
st = custodianState(t, 3, 380, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyRewindHP != 380 {
|
||||
t.Errorf("round 3 snapshot = %d, want 380", st.enemyRewindHP)
|
||||
}
|
||||
// A later round does not re-snapshot over the captured value.
|
||||
st.round = 5
|
||||
st.enemyHP = 200
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyRewindHP != 380 {
|
||||
t.Errorf("post-capture snapshot = %d, want it frozen at 380", st.enemyRewindHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAmendment_RewindOnceAtPhaseTwo(t *testing.T) {
|
||||
// Snapshot 400; boss now at 200, below the 0.45*500=225 phase-two line.
|
||||
st := custodianState(t, 6, 200, 500)
|
||||
st.enemyRewindHP = 400
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyHP != 400 {
|
||||
t.Errorf("post-rewind HP = %d, want restored to snapshot 400", st.enemyHP)
|
||||
}
|
||||
if !st.enemyRewindUsed {
|
||||
t.Error("rewind did not mark itself used")
|
||||
}
|
||||
if countEvents(st.events, "amendment_rewind") != 1 {
|
||||
t.Errorf("amendment_rewind events = %d, want 1", countEvents(st.events, "amendment_rewind"))
|
||||
}
|
||||
// A second crossing does not rewind again.
|
||||
st.enemyHP = 150
|
||||
mark := len(st.events)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyHP != 150 {
|
||||
t.Errorf("second-crossing HP = %d, want left at 150 (rewind spent)", st.enemyHP)
|
||||
}
|
||||
if len(st.events) != mark {
|
||||
t.Error("a spent rewind emitted another event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAmendment_NoRewindAbovePhaseTwo(t *testing.T) {
|
||||
// Boss above the phase-two line: no rewind even with a snapshot in hand.
|
||||
st := custodianState(t, 6, 300, 500) // 300 > 225
|
||||
st.enemyRewindHP = 450
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyHP != 300 || st.enemyRewindUsed {
|
||||
t.Errorf("HP=%d used=%v; want no rewind above phase two", st.enemyHP, st.enemyRewindUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAmendment_NoRewindWithoutSnapshot(t *testing.T) {
|
||||
// Bursted into phase two before round 3: no snapshot, so no free heal.
|
||||
st := custodianState(t, 2, 100, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyHP != 100 || st.enemyRewindUsed {
|
||||
t.Errorf("HP=%d used=%v; want no rewind without a snapshot", st.enemyHP, st.enemyRewindUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAmendment_MidnightTimer(t *testing.T) {
|
||||
// Before round 20: no attack climb.
|
||||
st := custodianState(t, 19, 300, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyAtkBuff != 0 {
|
||||
t.Errorf("round 19 atk buff = %d, want 0", st.enemyAtkBuff)
|
||||
}
|
||||
// Round 20+: +2 per round, capped.
|
||||
st = custodianState(t, 20, 300, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyAtkBuff != midnightAtkStep {
|
||||
t.Errorf("round 20 atk buff = %d, want %d", st.enemyAtkBuff, midnightAtkStep)
|
||||
}
|
||||
if countEvents(st.events, "midnight_toll") != 1 {
|
||||
t.Errorf("midnight_toll events = %d, want 1", countEvents(st.events, "midnight_toll"))
|
||||
}
|
||||
// The cap holds against a truly endless stall.
|
||||
st.enemyAtkBuff = midnightAtkCap
|
||||
mark := len(st.events)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyAtkBuff != midnightAtkCap {
|
||||
t.Errorf("capped atk buff = %d, want %d", st.enemyAtkBuff, midnightAtkCap)
|
||||
}
|
||||
if len(st.events) != mark {
|
||||
t.Error("midnight timer emitted a toll after hitting the cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBossInCombatRoundEnd_DispatchesOnlyCustodian(t *testing.T) {
|
||||
// A non-Custodian enemy is untouched even at a rewind-eligible HP.
|
||||
st := custodianState(t, 6, 200, 500)
|
||||
st.enemyRewindHP = 400
|
||||
applyBossInCombatRoundEnd(st, "boss_seraphel", 500)
|
||||
if st.enemyHP != 200 || st.enemyRewindUsed {
|
||||
t.Errorf("HP=%d used=%v; want no-op for a non-Custodian boss", st.enemyHP, st.enemyRewindUsed)
|
||||
}
|
||||
// The Custodian id does resolve the hook.
|
||||
applyBossInCombatRoundEnd(st, "boss_custodian", 500)
|
||||
if st.enemyHP != 400 || !st.enemyRewindUsed {
|
||||
t.Errorf("HP=%d used=%v; want the rewind for the Custodian", st.enemyHP, st.enemyRewindUsed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inversion Stitch (Seamstress) ────────────────────────────────────────────
|
||||
|
||||
// seamstressState builds a fully-formed combatState (embedded actor + roster) so
|
||||
// the event helpers that read st.playerHP resolve — a bare struct-literal state
|
||||
// has a nil actor cursor.
|
||||
func seamstressState(t *testing.T, enemyHP, enemyMaxHP int) *combatState {
|
||||
t.Helper()
|
||||
sess := turnSession(CombatPhaseRoundEnd, 10000, enemyHP)
|
||||
sess.EnemyID = "boss_seamstress"
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = enemyMaxHP
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.enemyHP = enemyHP
|
||||
return te.st
|
||||
}
|
||||
|
||||
func TestApplyInversionStitch_NoOpAbovePhaseTwo(t *testing.T) {
|
||||
// Above the 0.35*500=175 phase-two line the room stays right-side-out.
|
||||
st := seamstressState(t, 300, 500)
|
||||
applyInversionStitch(st, 500)
|
||||
if st.inversionTelegraph || st.inversionActive != 0 || len(st.events) != 0 {
|
||||
t.Errorf("above phase two: telegraph=%v active=%d events=%d, want all zero",
|
||||
st.inversionTelegraph, st.inversionActive, len(st.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInversionStitch_Cadence(t *testing.T) {
|
||||
// Below the phase-two line the room cycles warn(1) → sting(pulse) → warn(1)…
|
||||
st := seamstressState(t, 150, 500) // 150 < 0.35*500 = 175
|
||||
|
||||
// Round end 1: no pulse yet, so telegraph the first one.
|
||||
applyInversionStitch(st, 500)
|
||||
if !st.inversionTelegraph || st.inversionActive != 0 {
|
||||
t.Fatalf("after warn: telegraph=%v active=%d, want telegraph=true active=0",
|
||||
st.inversionTelegraph, st.inversionActive)
|
||||
}
|
||||
if countEvents(st.events, "inversion_telegraph") != 1 {
|
||||
t.Fatalf("telegraph events = %d, want 1", countEvents(st.events, "inversion_telegraph"))
|
||||
}
|
||||
|
||||
// Round end 2: the telegraphed pulse activates for its full duration.
|
||||
applyInversionStitch(st, 500)
|
||||
if st.inversionActive != inversionPulseRounds || st.inversionTelegraph {
|
||||
t.Fatalf("after activate: active=%d telegraph=%v, want active=%d telegraph=false",
|
||||
st.inversionActive, st.inversionTelegraph, inversionPulseRounds)
|
||||
}
|
||||
if countEvents(st.events, "inversion_stitch") != 1 {
|
||||
t.Fatalf("stitch events = %d, want 1", countEvents(st.events, "inversion_stitch"))
|
||||
}
|
||||
|
||||
// The pulse counts down one round per round end, staying active until it lapses.
|
||||
for r := inversionPulseRounds - 1; r >= 1; r-- {
|
||||
applyInversionStitch(st, 500)
|
||||
if st.inversionActive != r {
|
||||
t.Fatalf("mid-pulse active = %d, want %d", st.inversionActive, r)
|
||||
}
|
||||
}
|
||||
|
||||
// The round the pulse lapses (active 1→0) a fresh warn is scheduled in the
|
||||
// same round end — the repeating rhythm, never two silent rounds in a row.
|
||||
applyInversionStitch(st, 500)
|
||||
if st.inversionActive != 0 || !st.inversionTelegraph {
|
||||
t.Fatalf("after pulse: active=%d telegraph=%v, want active=0 telegraph=true",
|
||||
st.inversionActive, st.inversionTelegraph)
|
||||
}
|
||||
if countEvents(st.events, "inversion_telegraph") != 2 {
|
||||
t.Fatalf("telegraph events = %d, want 2 (the next pulse warned)",
|
||||
countEvents(st.events, "inversion_telegraph"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBossInCombatRoundEnd_DispatchesSeamstress(t *testing.T) {
|
||||
// A non-Seamstress boss at a phase-two HP is untouched.
|
||||
st := seamstressState(t, 150, 500)
|
||||
applyBossInCombatRoundEnd(st, "boss_aurvandryx", 500)
|
||||
if st.inversionTelegraph || len(st.events) != 0 {
|
||||
t.Errorf("non-Seamstress boss triggered inversion (telegraph=%v events=%d)",
|
||||
st.inversionTelegraph, len(st.events))
|
||||
}
|
||||
// The Seamstress id resolves the hook.
|
||||
applyBossInCombatRoundEnd(st, "boss_seamstress", 500)
|
||||
if !st.inversionTelegraph {
|
||||
t.Error("boss_seamstress did not schedule the inversion telegraph")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInversionStitch_HealsSting drives a real round with an active pulse seeded
|
||||
// and confirms both the self-heal and the ally-heal land as wounds, floored at 1.
|
||||
func TestInversionStitch_HealsSting(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
t.Run("ally heal stings the friend", func(t *testing.T) {
|
||||
sess := startAllyHealFight(t, p, 50) // friend hurt to 50/100
|
||||
sess.Statuses.InversionActive = 1 // room is inside-out this round
|
||||
healer, friend := basePlayer(), basePlayer()
|
||||
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||
enemy: &Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
|
||||
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||
before := sess.seatHP(1)
|
||||
|
||||
events, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
|
||||
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 30, AllySeat: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sess.seatHP(1); got >= before {
|
||||
t.Errorf("friend HP = %d (was %d) — an inverted heal must wound, not mend", got, before)
|
||||
}
|
||||
if countEvents(events, "heal_inverted") != 1 {
|
||||
t.Errorf("heal_inverted events = %d, want 1", countEvents(events, "heal_inverted"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("self heal stings the acting seat, floored at 1", func(t *testing.T) {
|
||||
// A self-heal (PlayerHeal, no AllySeat) lands on whichever seat is acting;
|
||||
// assert the inversion fired (heal_inverted) and no seat was driven to 0 —
|
||||
// the sting denies sustain, it never kills.
|
||||
sess := startAllyHealFight(t, p, 100)
|
||||
sess.Statuses.InversionActive = 1
|
||||
healer, friend := basePlayer(), basePlayer()
|
||||
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||
enemy: &Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
|
||||
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||
|
||||
events, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
|
||||
Effect: &turnActionEffect{Label: "Cure Self", Action: "spell_cast", PlayerHeal: 30}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if countEvents(events, "heal_inverted") != 1 {
|
||||
t.Errorf("heal_inverted events = %d, want 1 — the self-heal did not invert", countEvents(events, "heal_inverted"))
|
||||
}
|
||||
for seat := 0; seat < 2; seat++ {
|
||||
if got := sess.seatHP(seat); got < 1 {
|
||||
t.Errorf("seat %d HP = %d — the sting must floor at 1, never kill", seat, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTurnEngine_AmendmentRoundTrips(t *testing.T) {
|
||||
// Drive a real Custodian round-end and confirm the once-only rewind is
|
||||
// captured through CombatStatuses (a suspend/resume can't replay it).
|
||||
sess := turnSession(CombatPhaseRoundEnd, 10000, 200)
|
||||
sess.Round = 6
|
||||
sess.EnemyID = "boss_custodian"
|
||||
sess.EnemyHPMax = 500
|
||||
sess.Statuses.EnemyRewindHP = 400 // snapshot taken earlier in the fight
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 500
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if sess.EnemyHP != 400 {
|
||||
t.Errorf("committed EnemyHP = %d, want the 400 rewind pool", sess.EnemyHP)
|
||||
}
|
||||
if !sess.Statuses.EnemyRewindUsed {
|
||||
t.Error("EnemyRewindUsed did not persist through commit")
|
||||
}
|
||||
if countEvents(sess.TurnLog, "amendment_rewind") != 1 {
|
||||
t.Errorf("amendment_rewind events = %d, want 1", countEvents(sess.TurnLog, "amendment_rewind"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_InversionRoundTrips(t *testing.T) {
|
||||
// Drive a real Seamstress round-end in phase 2 and confirm the scheduled
|
||||
// telegraph persists through CombatStatuses (a suspend/resume keeps the cadence).
|
||||
sess := turnSession(CombatPhaseRoundEnd, 10000, 150) // 150 < 0.35*500 = 175
|
||||
sess.EnemyID = "boss_seamstress"
|
||||
sess.EnemyHPMax = 500
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 500
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if !sess.Statuses.InversionTelegraph {
|
||||
t.Error("InversionTelegraph did not persist through commit")
|
||||
}
|
||||
if countEvents(sess.TurnLog, "inversion_telegraph") != 1 {
|
||||
t.Errorf("inversion_telegraph events = %d, want 1", countEvents(sess.TurnLog, "inversion_telegraph"))
|
||||
}
|
||||
}
|
||||
117
internal/plugin/postgame_magic_items.go
Normal file
117
internal/plugin/postgame_magic_items.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package plugin
|
||||
|
||||
// Post-game (Tier 6) signature magic items — the ten hand-authored Legendary
|
||||
// drops from the five Mythic zones. Two per zone; the third zone drop is a
|
||||
// UniqueAlways crafting anchor (a treasure material, not an equippable), handled
|
||||
// on the loot side.
|
||||
//
|
||||
// These are real magicItemRegistry entries so the loot path can grant them as
|
||||
// equippable magic_item rows (see zoneLootToInventory) and the equip pipeline
|
||||
// can resolve them. Their combat numbers live in postgameSignatureEffects, the
|
||||
// magicItemEffectOverlay layer — bespoke, above the codified Legendary formula,
|
||||
// because these are meant to be the best-in-slot chase items. Numbers are
|
||||
// opening bids; the P7 sim sweep tunes them.
|
||||
//
|
||||
// The fancier one-shot riders each Note in postgame_zone_defs.go promises
|
||||
// (survive-a-killing-blow, refund-the-slot, negate-a-stun, auto-evade, …) are
|
||||
// Layer-2 boss/effect hooks deferred to P8 — encoded here is only what the
|
||||
// magicItemEffect struct can already express.
|
||||
|
||||
// postgameSignatureMagicItems is wired into magicItemOverlay (magic_items.go)
|
||||
// as a plain var reference, so it resolves before buildMagicItemRegistry() runs.
|
||||
var postgameSignatureMagicItems = []MagicItem{
|
||||
// ── The Ossuary Ascendant ────────────────────────────────────────────────
|
||||
{
|
||||
ID: "crown_of_the_patient_king", Name: "Crown of the Patient King",
|
||||
Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotHead, Value: 8000,
|
||||
Desc: "A thin band of black iron, warm as if just taken off. He respects a good plan; wear it and the fight waits for yours.",
|
||||
},
|
||||
{
|
||||
ID: "valdris_quill", Name: "Valdris's Quill",
|
||||
Kind: MagicItemWand, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotMainHand, Value: 8000,
|
||||
Desc: "The lich's own writing implement, still wet. Spells drawn with it land where they were aimed and then some.",
|
||||
},
|
||||
|
||||
// ── The First Hoard ──────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "aegis_of_the_first_scale", Name: "Aegis of the First Scale",
|
||||
Kind: MagicItemArmor, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotChest, Value: 9000,
|
||||
Desc: "A single shed scale of the Ember Before Fire, worked into a breastplate. Fire predates it and still cannot touch it.",
|
||||
},
|
||||
{
|
||||
ID: "emberheart_greatblade", Name: "Emberheart Greatblade",
|
||||
Kind: MagicItemWeapon, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotMainHand, Value: 8000,
|
||||
Desc: "Forged around a coal that has never cooled. Its edge opens wounds that go on burning after the swing.",
|
||||
},
|
||||
|
||||
// ── The Unplace ──────────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "needle_of_the_seam", Name: "Needle of the Seam",
|
||||
Kind: MagicItemWeapon, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotMainHand, Value: 8000,
|
||||
Desc: "The Seamstress's own needle, half here and half elsewhere. Spellwork slid along it finds the gaps in everything.",
|
||||
},
|
||||
{
|
||||
ID: "mantle_of_elsewhere", Name: "Mantle of Elsewhere",
|
||||
Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotCloak, Value: 8000,
|
||||
Desc: "A cloak cut from a room that isn't. Now and then a blow passes through where you briefly were not.",
|
||||
},
|
||||
|
||||
// ── The Drowned Star ─────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "halo_of_the_sunken_dawn", Name: "Halo of the Sunken Dawn",
|
||||
Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotHead, Value: 8000,
|
||||
Desc: "A ring of drowned light that never went out. Mercy carried through it lands heavier on those you mean to keep alive.",
|
||||
},
|
||||
{
|
||||
ID: "tideglass_ward", Name: "Tideglass Ward",
|
||||
Kind: MagicItemArmor, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotOffHand, Value: 8000,
|
||||
Desc: "A shield of pressure-fused abyssal glass. Once a fight it drinks a wave whole and gives you back the room.",
|
||||
},
|
||||
|
||||
// ── The Last Meridian ────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "the_second_hand", Name: "The Second Hand",
|
||||
Kind: MagicItemWeapon, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotMainHand, Value: 8000,
|
||||
Desc: "A slim blade that keeps its own time. Now and then it lends you a moment you did not have to swing again.",
|
||||
},
|
||||
{
|
||||
ID: "escapement_plate", Name: "Escapement Plate",
|
||||
Kind: MagicItemArmor, Rarity: RarityLegendary, Attunement: true,
|
||||
Slot: DnDSlotChest, Value: 8000,
|
||||
Desc: "Verdigris plate geared like a clock. The first blow that would stop you finds a moment already held in reserve.",
|
||||
},
|
||||
}
|
||||
|
||||
// postgameSignatureEffects is wired into magicItemEffectOverlay
|
||||
// (magic_items_gameplay.go). DamageReductMult is multiplicative (1.0 neutral,
|
||||
// <1.0 reduces damage taken); the aggregator ignores a 0.0, so weapons/wondrous
|
||||
// items that don't reduce damage simply leave it unset. Everything else is
|
||||
// additive. Legendary codified baselines for reference: weapon DamageBonus 0.25,
|
||||
// armor DamageReductMult 0.80, wand DamageBonus 0.15 + FlatDmgStart 5, wondrous
|
||||
// InitiativeBias 2.5 + MaxHP 10 — these sit at or above those.
|
||||
var postgameSignatureEffects = map[string]magicItemEffect{
|
||||
// Ossuary
|
||||
"crown_of_the_patient_king": {InitiativeBias: 3.0, MaxHP: 14},
|
||||
"valdris_quill": {DamageBonus: 0.20, FlatDmgStart: 6},
|
||||
// First Hoard
|
||||
"aegis_of_the_first_scale": {DamageReductMult: 0.72, MaxHP: 10},
|
||||
"emberheart_greatblade": {DamageBonus: 0.30, FlatDmgStart: 6},
|
||||
// Unplace
|
||||
"needle_of_the_seam": {DamageBonus: 0.30, FlatDmgStart: 4},
|
||||
"mantle_of_elsewhere": {DamageReductMult: 0.88, InitiativeBias: 2.0},
|
||||
// Drowned Star
|
||||
"halo_of_the_sunken_dawn": {DamageReductMult: 0.92, InitiativeBias: 2.0, MaxHP: 12},
|
||||
"tideglass_ward": {DamageReductMult: 0.80},
|
||||
// Last Meridian
|
||||
"the_second_hand": {DamageBonus: 0.30, InitiativeBias: 3.0},
|
||||
"escapement_plate": {DamageReductMult: 0.74, MaxHP: 8},
|
||||
}
|
||||
336
internal/plugin/postgame_zone_defs.go
Normal file
336
internal/plugin/postgame_zone_defs.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package plugin
|
||||
|
||||
// Tier 6 "Mythic" post-game zone definitions + region registries (Phase P3).
|
||||
//
|
||||
// Five hand-authored endgame zones, each four regions deep, gated behind
|
||||
// postgameUnlocked. Standards are demoted T4/T5 monsters (the tier
|
||||
// statement); elites and bosses are the bespoke stat blocks in
|
||||
// postgame_bestiary.go. Loot entries here are declared but their drop
|
||||
// mechanics (boss-only signature roll, magicItemEffectOverlay, pity
|
||||
// recipes) are wired in Phase P5.
|
||||
//
|
||||
// Registration runs from this file's init(), which executes after
|
||||
// dnd_zone.go's init() (filename order), so T6 zones append to zoneOrder
|
||||
// after T1–T5. Hooks/Atmosphere are TwinBee first-person voice.
|
||||
|
||||
func init() {
|
||||
registerZone(zoneOssuaryAscendant())
|
||||
registerZone(zoneFirstHoard())
|
||||
registerZone(zoneUnplace())
|
||||
registerZone(zoneDrownedStar())
|
||||
registerZone(zoneLastMeridian())
|
||||
|
||||
regionsByZone[ZoneOssuaryAscendant] = []ExpeditionRegion{
|
||||
{ID: "ossuary_bonefall_steps", ZoneID: ZoneOssuaryAscendant, Order: 1,
|
||||
Name: "Bonefall Steps", BaseCampSite: false,
|
||||
RegionBoss: "The Chorister",
|
||||
EnemySubset: []string{"wight", "revenant", "the_chorister"}},
|
||||
{ID: "ossuary_cathedral_marrow", ZoneID: ZoneOssuaryAscendant, Order: 2,
|
||||
Name: "Cathedral of Marrow", BaseCampSite: true,
|
||||
RegionBoss: "Grave Cardinal",
|
||||
EnemySubset: []string{"banshee", "wraith", "grave_cardinal"}},
|
||||
{ID: "ossuary_reliquary_vaults", ZoneID: ZoneOssuaryAscendant, Order: 3,
|
||||
Name: "Reliquary Vaults", BaseCampSite: true,
|
||||
RegionBoss: "Reliquary Knight",
|
||||
EnemySubset: []string{"wraith", "revenant", "reliquary_knight"}},
|
||||
{ID: "ossuary_apotheosis_engine", ZoneID: ZoneOssuaryAscendant, Order: 4,
|
||||
Name: "The Apotheosis Engine", BaseCampSite: false,
|
||||
RegionBoss: "Valdris, At Last (Zone Boss)", IsZoneBoss: true,
|
||||
EnemySubset: []string{"wight", "banshee"}},
|
||||
}
|
||||
regionsByZone[ZoneFirstHoard] = []ExpeditionRegion{
|
||||
{ID: "hoard_cooling_throne", ZoneID: ZoneFirstHoard, Order: 1,
|
||||
Name: "The Cooling Throne", BaseCampSite: false,
|
||||
RegionBoss: "Choir Drake Matriarch",
|
||||
EnemySubset: []string{"salamander", "helmed_horror", "choir_drake_matriarch"}},
|
||||
{ID: "hoard_gilded_gullet", ZoneID: ZoneFirstHoard, Order: 2,
|
||||
Name: "Gilded Gullet", BaseCampSite: true,
|
||||
RegionBoss: "Coinborn Simulacrum",
|
||||
EnemySubset: []string{"helmed_horror", "young_red_dragon", "coinborn_simulacrum"}},
|
||||
{ID: "hoard_clutch_vaults", ZoneID: ZoneFirstHoard, Order: 3,
|
||||
Name: "The Clutch Vaults", BaseCampSite: true,
|
||||
RegionBoss: "Wyrm-Sworn Executor",
|
||||
EnemySubset: []string{"young_red_dragon", "salamander", "wyrm_sworn_executor"}},
|
||||
{ID: "hoard_cradle_first_flame", ZoneID: ZoneFirstHoard, Order: 4,
|
||||
Name: "Cradle of the First Flame", BaseCampSite: false,
|
||||
RegionBoss: "Aurvandryx (Zone Boss)", IsZoneBoss: true,
|
||||
EnemySubset: []string{"young_red_dragon"}},
|
||||
}
|
||||
regionsByZone[ZoneUnplace] = []ExpeditionRegion{
|
||||
{ID: "unplace_the_fray", ZoneID: ZoneUnplace, Order: 1,
|
||||
Name: "The Fray", BaseCampSite: false,
|
||||
RegionBoss: "Angleworn Horror",
|
||||
EnemySubset: []string{"hezrou", "nalfeshnee", "angleworn_horror"}},
|
||||
{ID: "unplace_eschers_debt", ZoneID: ZoneUnplace, Order: 2,
|
||||
Name: "Escher's Debt", BaseCampSite: true,
|
||||
RegionBoss: "The Unnumbered",
|
||||
EnemySubset: []string{"nalfeshnee", "marilith", "the_unnumbered"}},
|
||||
{ID: "unplace_stitchworks", ZoneID: ZoneUnplace, Order: 3,
|
||||
Name: "The Stitchworks", BaseCampSite: true,
|
||||
RegionBoss: "Echo of Belaxath",
|
||||
EnemySubset: []string{"marilith", "hezrou", "echo_of_belaxath"}},
|
||||
{ID: "unplace_the_seam", ZoneID: ZoneUnplace, Order: 4,
|
||||
Name: "The Seam", BaseCampSite: false,
|
||||
RegionBoss: "The Seamstress (Zone Boss)", IsZoneBoss: true,
|
||||
EnemySubset: []string{"marilith"}},
|
||||
}
|
||||
regionsByZone[ZoneDrownedStar] = []ExpeditionRegion{
|
||||
{ID: "star_long_sink", ZoneID: ZoneDrownedStar, Order: 1,
|
||||
Name: "The Long Sink", BaseCampSite: false,
|
||||
RegionBoss: "Choir of Static",
|
||||
EnemySubset: []string{"merrow", "water_elemental", "choir_of_static"}},
|
||||
{ID: "star_pilgrim_trench", ZoneID: ZoneDrownedStar, Order: 2,
|
||||
Name: "Pilgrim Trench", BaseCampSite: true,
|
||||
RegionBoss: "Lantern Warden",
|
||||
EnemySubset: []string{"aboleth_thrall", "water_elemental", "lantern_warden"}},
|
||||
{ID: "star_radiant_wreck", ZoneID: ZoneDrownedStar, Order: 3,
|
||||
Name: "The Radiant Wreck", BaseCampSite: true,
|
||||
RegionBoss: "Star-Gorged Leviathanling",
|
||||
EnemySubset: []string{"aboleth_thrall", "merrow", "star_gorged_leviathanling"}},
|
||||
{ID: "star_heart_chapel", ZoneID: ZoneDrownedStar, Order: 4,
|
||||
Name: "The Heart Chapel", BaseCampSite: false,
|
||||
RegionBoss: "Seraphel (Zone Boss)", IsZoneBoss: true,
|
||||
EnemySubset: []string{"water_elemental"}},
|
||||
}
|
||||
regionsByZone[ZoneLastMeridian] = []ExpeditionRegion{
|
||||
{ID: "meridian_dusk_colonnade", ZoneID: ZoneLastMeridian, Order: 1,
|
||||
Name: "The Dusk Colonnade", BaseCampSite: false,
|
||||
RegionBoss: "The Hour Thief",
|
||||
EnemySubset: []string{"specter", "wraith", "the_hour_thief"}},
|
||||
{ID: "meridian_gallery_spent_hours", ZoneID: ZoneLastMeridian, Order: 2,
|
||||
Name: "Gallery of Spent Hours", BaseCampSite: true,
|
||||
RegionBoss: "Wardens-in-Waiting",
|
||||
EnemySubset: []string{"wraith", "helmed_horror", "wardens_in_waiting"}},
|
||||
{ID: "meridian_escapement", ZoneID: ZoneLastMeridian, Order: 3,
|
||||
Name: "The Escapement", BaseCampSite: true,
|
||||
RegionBoss: "Pendulum Colossus",
|
||||
EnemySubset: []string{"helmed_horror", "specter", "pendulum_colossus"}},
|
||||
{ID: "meridian_one_minute", ZoneID: ZoneLastMeridian, Order: 4,
|
||||
Name: "One Minute To Midnight", BaseCampSite: false,
|
||||
RegionBoss: "The Custodian (Zone Boss)", IsZoneBoss: true,
|
||||
EnemySubset: []string{"helmed_horror", "wraith"}},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tier 6 zone factories --------------------------------------------------
|
||||
|
||||
func zoneOssuaryAscendant() ZoneDefinition {
|
||||
return ZoneDefinition{
|
||||
ID: ZoneOssuaryAscendant,
|
||||
Display: "The Ossuary Ascendant",
|
||||
Tier: ZoneTierMythic,
|
||||
LevelMin: 18,
|
||||
LevelMax: 20,
|
||||
Faction: "Undead, Lich-ascendant",
|
||||
Atmosphere: "An inverted bone cathedral hanging over the old Crypt; marrow-light, choral static, the smell of a very patient plan finally paying off.",
|
||||
Hook: "Valdris made it. Dying in the Crypt was step one all along — the phylactery shard you looted was bait. He has rebuilt himself into a true lich, one vertebra at a time, and hung his cathedral upside-down over his own grave. Your oldest quest item has come home. I do not like it here.",
|
||||
MinRooms: 44,
|
||||
MaxRooms: 52,
|
||||
Enemies: []ZoneEnemy{
|
||||
{BestiaryID: "wight", SpawnWeight: 6},
|
||||
{BestiaryID: "revenant", SpawnWeight: 5},
|
||||
{BestiaryID: "banshee", SpawnWeight: 4},
|
||||
{BestiaryID: "wraith", SpawnWeight: 4},
|
||||
{BestiaryID: "the_chorister", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "grave_cardinal", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "reliquary_knight", SpawnWeight: 2, IsElite: true},
|
||||
},
|
||||
Boss: ZoneBoss{
|
||||
BestiaryID: "boss_valdris_ascendant",
|
||||
Name: "Valdris, At Last",
|
||||
CR: 26,
|
||||
HP: 470,
|
||||
AC: 21,
|
||||
PhaseTwoAt: 0.50,
|
||||
Description: "The lich-aspirant from the Crypt, ascended. He respects a good plan. He has been running one on you for years.",
|
||||
Abilities: []string{
|
||||
"Apotheosis Nova: decisive AoE, armor-piercing radiant-necrotic burst",
|
||||
"Phase 2 (<50% HP): flies; nova recharges faster",
|
||||
"Phylactery Verses (Layer 2): three secret Verses in the zone each strip one Legendary Resistance / rebirth proc if found first",
|
||||
},
|
||||
},
|
||||
Loot: []ZoneLootEntry{
|
||||
{ItemID: "crown_of_the_patient_king", DropChance: 0.04, BossOnly: true, Note: "head, Legendary: +3 INT & WIS; 1/run survive a killing blow at 1 HP"},
|
||||
{ItemID: "valdris_quill", DropChance: 0.04, BossOnly: true, Note: "caster weapon, Legendary: spell attacks +3; on spell kill refund the slot (1/combat)"},
|
||||
{ItemID: "verse_bound_folio", UniqueAlways: true, Note: "T6 crafting anchor"},
|
||||
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
|
||||
},
|
||||
FlavorFile: "zone_ossuary_ascendant_flavor.go",
|
||||
}
|
||||
}
|
||||
|
||||
func zoneFirstHoard() ZoneDefinition {
|
||||
return ZoneDefinition{
|
||||
ID: ZoneFirstHoard,
|
||||
Display: "The First Hoard",
|
||||
Tier: ZoneTierMythic,
|
||||
LevelMin: 18,
|
||||
LevelMax: 20,
|
||||
Faction: "Dragons, Wyrm-cult, the Hoard itself",
|
||||
Atmosphere: "Beneath Infernus Peak, still warm. Rivers of gilded scale, a floor of minted antibodies, and under it all the slow breathing of something that was old when fire was new.",
|
||||
Hook: "Infernax is dead and the mountain is still warm — because he was never the owner. He was the guard dog. Beneath the peak sleeps Aurvandryx, the Ember Before Fire, whose shed scales became every dragon you have ever met. The hoard isn't treasure. It's her clutch, gilded over. I suggest we not wake the mother.",
|
||||
MinRooms: 44,
|
||||
MaxRooms: 52,
|
||||
Enemies: []ZoneEnemy{
|
||||
{BestiaryID: "salamander", SpawnWeight: 6},
|
||||
{BestiaryID: "helmed_horror", SpawnWeight: 5},
|
||||
{BestiaryID: "young_red_dragon", SpawnWeight: 3},
|
||||
{BestiaryID: "choir_drake_matriarch", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "coinborn_simulacrum", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "wyrm_sworn_executor", SpawnWeight: 2, IsElite: true},
|
||||
},
|
||||
Boss: ZoneBoss{
|
||||
BestiaryID: "boss_aurvandryx",
|
||||
Name: "Aurvandryx, the Ember Before Fire",
|
||||
CR: 28,
|
||||
HP: 540,
|
||||
AC: 22,
|
||||
PhaseTwoAt: 0.40,
|
||||
Description: "The progenitor wyrm. Fire that predates fire resistance. She is not defending treasure; she is defending eggs.",
|
||||
Abilities: []string{
|
||||
"First Flame: decisive fire AoE that ignores resistance (phase-2 narration notes it failing)",
|
||||
"Phase 2 (<40% HP): grows; First Flame recharges faster",
|
||||
"Greed Tax (Layer 2): her Attack scales +1 per N loot drops picked up during the run",
|
||||
},
|
||||
},
|
||||
Loot: []ZoneLootEntry{
|
||||
{ItemID: "aegis_of_the_first_scale", DropChance: 0.05, BossOnly: true, Note: "armor, Legendary (best in game): AC +3, fire immunity, 1/run ignore a killing blow over half max HP"},
|
||||
{ItemID: "emberheart_greatblade", DropChance: 0.04, BossOnly: true, Note: "weapon, Legendary: +3, +3d6 fire, crits strip target BlockRate next round"},
|
||||
{ItemID: "cooling_ember_of_aurvandryx", UniqueAlways: true, Note: "T6 crafting anchor — the legendary-fire capstone thyraks_core has been waiting for"},
|
||||
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
|
||||
},
|
||||
FlavorFile: "zone_first_hoard_flavor.go",
|
||||
}
|
||||
}
|
||||
|
||||
func zoneUnplace() ZoneDefinition {
|
||||
return ZoneDefinition{
|
||||
ID: ZoneUnplace,
|
||||
Display: "The Unplace",
|
||||
Tier: ZoneTierMythic,
|
||||
LevelMin: 18,
|
||||
LevelMax: 20,
|
||||
Faction: "Demons, geometry itself, a corrupted celestial",
|
||||
Atmosphere: "A wound in space that geometry gave up on. Rooms that are also other rooms. A horizon indoors. Something is stitching it shut from the inside, and it is not doing it to help.",
|
||||
Hook: "Belaxath tore the portal open over thirty years; killing him didn't close it — it left the tear unattended. The wound has scarred into a region where a corner can be three rooms away and a straight line arrives before it leaves. I have stopped trusting the map. I advise you stop trusting the walls.",
|
||||
MinRooms: 44,
|
||||
MaxRooms: 52,
|
||||
Enemies: []ZoneEnemy{
|
||||
{BestiaryID: "hezrou", SpawnWeight: 6},
|
||||
{BestiaryID: "nalfeshnee", SpawnWeight: 4},
|
||||
{BestiaryID: "marilith", SpawnWeight: 3},
|
||||
{BestiaryID: "angleworn_horror", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "the_unnumbered", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "echo_of_belaxath", SpawnWeight: 2, IsElite: true},
|
||||
},
|
||||
Boss: ZoneBoss{
|
||||
BestiaryID: "boss_seamstress",
|
||||
Name: "The Seamstress",
|
||||
CR: 27,
|
||||
HP: 480,
|
||||
AC: 21,
|
||||
PhaseTwoAt: 0.35,
|
||||
Description: "A corrupted celestial who volunteered to close the tear centuries early and has been sewing herself into it ever since. Half of her is on the other side, and the far half does most of the talking.",
|
||||
Abilities: []string{
|
||||
"Needle Rain: decisive AoE of driven needles",
|
||||
"Phase 2 (<35% HP): the room sews inside-out",
|
||||
"Inversion Stitch (Layer 2): healing and damage swap direction on her in telegraphed 2-round pulses",
|
||||
},
|
||||
},
|
||||
Loot: []ZoneLootEntry{
|
||||
{ItemID: "needle_of_the_seam", DropChance: 0.04, BossOnly: true, Note: "dagger, Legendary (best caster weapon): +3; spell crit range +1; on crit next spell is free"},
|
||||
{ItemID: "mantle_of_elsewhere", DropChance: 0.05, BossOnly: true, Note: "cloak: +2 AC; 1/combat auto-evade one hit"},
|
||||
{ItemID: "spool_of_undone_geometry", UniqueAlways: true, Note: "T6 crafting anchor"},
|
||||
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
|
||||
},
|
||||
FlavorFile: "zone_unplace_flavor.go",
|
||||
}
|
||||
}
|
||||
|
||||
func zoneDrownedStar() ZoneDefinition {
|
||||
return ZoneDefinition{
|
||||
ID: ZoneDrownedStar,
|
||||
Display: "The Drowned Star",
|
||||
Tier: ZoneTierMythic,
|
||||
LevelMin: 18,
|
||||
LevelMax: 20,
|
||||
Faction: "Aboleth-touched, abyssal pilgrims, a fallen angel",
|
||||
Atmosphere: "An abyssal trench lit from below by a dying star, and by the angel who rode it down. The pilgrimage route glows. Both the star and its guardian have gone strange in ten thousand years of dark.",
|
||||
Hook: "The Dreaming Aboleth has been dreaming of one specific thing this whole time: a star that fell into the trench before the surface had names, with its angel still strapped to it. Seraphel rode her charge down rather than abandon it, and has spent ten thousand years keeping a dying star alive with radiance meant for healing. Both of them have gone strange. I would too.",
|
||||
MinRooms: 44,
|
||||
MaxRooms: 52,
|
||||
Enemies: []ZoneEnemy{
|
||||
{BestiaryID: "merrow", SpawnWeight: 6},
|
||||
{BestiaryID: "water_elemental", SpawnWeight: 5},
|
||||
{BestiaryID: "aboleth_thrall", SpawnWeight: 3},
|
||||
{BestiaryID: "choir_of_static", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "lantern_warden", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "star_gorged_leviathanling", SpawnWeight: 2, IsElite: true},
|
||||
},
|
||||
Boss: ZoneBoss{
|
||||
BestiaryID: "boss_seraphel",
|
||||
Name: "Seraphel, the Light That Sank",
|
||||
CR: 27,
|
||||
HP: 460,
|
||||
AC: 21,
|
||||
PhaseTwoAt: 0.50,
|
||||
Description: "An angel who would not let go. Ten thousand years of shielding a dying star with healing light have made her something that grieves in radiant bursts.",
|
||||
Abilities: []string{
|
||||
"Sanctified Undertow: decisive radiant AoE",
|
||||
"Phase 2 (<50% HP): grieving — faster, sloppier",
|
||||
"Two Hearts (Layer 2): a second ~150 HP Star-Heart pool; burn it and she enrages, spare it and she never does (hidden mercy loot bias)",
|
||||
},
|
||||
},
|
||||
Loot: []ZoneLootEntry{
|
||||
{ItemID: "halo_of_the_sunken_dawn", DropChance: 0.04, BossOnly: true, Note: "head, Legendary: +2 all saves; ally-heals you cast +50%"},
|
||||
{ItemID: "tideglass_ward", DropChance: 0.05, BossOnly: true, Note: "shield: +3 AC contribution; 1/combat negate an AoE proc entirely"},
|
||||
{ItemID: "ember_of_the_drowned_star", UniqueAlways: true, Note: "T6 crafting anchor"},
|
||||
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
|
||||
},
|
||||
FlavorFile: "zone_drowned_star_flavor.go",
|
||||
}
|
||||
}
|
||||
|
||||
func zoneLastMeridian() ZoneDefinition {
|
||||
return ZoneDefinition{
|
||||
ID: ZoneLastMeridian,
|
||||
Display: "The Last Meridian",
|
||||
Tier: ZoneTierMythic,
|
||||
LevelMin: 18,
|
||||
LevelMax: 20,
|
||||
Faction: "Clock-constructs, horologist ghosts, the Custodian",
|
||||
Atmosphere: "A necropolis-observatory where timekeeping was invented and is now being quietly decommissioned. Rooms you visited at dusk are midnight when you return. The final region plays in the second the clock stops.",
|
||||
Hook: "This is where the world's hours were invented — and where they are being taken apart. The Custodian has decided its contract is complete and is dismantling the hours one at a time on its way out. It is unfailingly polite about it. It will apologize before each swing. I find that worse, somehow.",
|
||||
MinRooms: 44,
|
||||
MaxRooms: 52,
|
||||
Enemies: []ZoneEnemy{
|
||||
{BestiaryID: "specter", SpawnWeight: 6},
|
||||
{BestiaryID: "wraith", SpawnWeight: 5},
|
||||
{BestiaryID: "helmed_horror", SpawnWeight: 3},
|
||||
{BestiaryID: "the_hour_thief", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "wardens_in_waiting", SpawnWeight: 3, IsElite: true},
|
||||
{BestiaryID: "pendulum_colossus", SpawnWeight: 2, IsElite: true},
|
||||
},
|
||||
Boss: ZoneBoss{
|
||||
BestiaryID: "boss_custodian",
|
||||
Name: "The Custodian of the Last Hour",
|
||||
CR: 27,
|
||||
HP: 500,
|
||||
AC: 21,
|
||||
PhaseTwoAt: 0.45,
|
||||
Description: "A titanic clock-golem of verdigris and orrery rings, closing out its contract. It apologizes, on schedule, before it kills you.",
|
||||
Abilities: []string{
|
||||
"Chime: decisive AoE on the hour",
|
||||
"Phase 2 (<45% HP): rewinds",
|
||||
"Amendment (Layer 2): once, rewinds itself to its round-3 HP snapshot; soft midnight timer adds +2 Atk/round past round 20",
|
||||
},
|
||||
},
|
||||
Loot: []ZoneLootEntry{
|
||||
{ItemID: "the_second_hand", DropChance: 0.04, BossOnly: true, Note: "weapon, Legendary (best martial sidearm): +3; 1/combat immediate extra attack; on kill refund the use"},
|
||||
{ItemID: "escapement_plate", DropChance: 0.05, BossOnly: true, Note: "armor: +3 AC; first stun each combat is negated"},
|
||||
{ItemID: "unspent_hour", UniqueAlways: true, Note: "T6 crafting anchor"},
|
||||
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
|
||||
},
|
||||
FlavorFile: "zone_last_meridian_flavor.go",
|
||||
}
|
||||
}
|
||||
220
internal/plugin/postgame_zone_graph_builder.go
Normal file
220
internal/plugin/postgame_zone_graph_builder.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package plugin
|
||||
|
||||
// Shared construction for the five Tier 6 "Mythic" post-game zone graphs
|
||||
// (Phase P4). All five share one topology so the entry→boss longest walk
|
||||
// lands deterministically in the T6 [44,52] band (46 by construction) and
|
||||
// so the four regions are evenly weighted sub-dungeons. Per-zone identity
|
||||
// lives entirely in the pgZoneSpec labels; structural set-pieces (Ossuary's
|
||||
// SECRET Verses, the First Hoard's game-max LootBias gilded route) ride the
|
||||
// Overrides map so the skeleton stays flavor-agnostic.
|
||||
//
|
||||
// Topology (counts give the longest walk = 46 nodes; every fork's first
|
||||
// spur is a LockNone edge so no run can be stranded — the graph-wide
|
||||
// TestZoneGraphs_NoSoftLockedFork invariant):
|
||||
//
|
||||
// R1 (region[0]): entry ─ p1..p12 ─ fork1 (14)
|
||||
// R2 (region[1]): fork1 ⇒ {free | locked} 3-spur ─ r2gate
|
||||
// ─ r2b1..6 ─ fork2 (+11)
|
||||
// R3 (region[2]): fork2 ⇒ {free | locked} 3-spur ─ r3gate
|
||||
// ─ r3b1..6 ─ fork3 (+11)
|
||||
// R4 (region[3]): fork3 ⇒ 3× capstone 3-spur ─ merge
|
||||
// ─ ap1..5 ─ boss (+10)
|
||||
//
|
||||
// fork1 / fork2 are binary (free + one locked spur); fork3 is the ternary
|
||||
// capstone (free + two locked spurs), each capstone leaf reaching the boss
|
||||
// through the merge. Node-id suffixes are stable and referenced by the
|
||||
// per-zone tests: entry, p1..p12, fork1, f1a1..3 (free) / f1b1..3 (locked),
|
||||
// r2gate, r2b1..6, fork2, f2a1..3 / f2b1..3, r3gate, r3b1..6, fork3,
|
||||
// cap11..13 (free) / cap21..23 (lockA) / cap31..33 (lockB), merge, ap1..5,
|
||||
// boss. Baseline anchors (a mid-run trap + two region-guardian elites) are
|
||||
// stamped by the builder; a spec Override can retype any node on top.
|
||||
|
||||
import "fmt"
|
||||
|
||||
// pgLock describes the gate on a fork's spur-entry edge. The zero value is
|
||||
// an unlocked (LockNone) edge.
|
||||
type pgLock struct {
|
||||
kind ZoneEdgeLockKind
|
||||
lockData map[string]any
|
||||
hint string
|
||||
}
|
||||
|
||||
// pgNodeOverride retypes and/or biases a single node by suffix. An empty
|
||||
// kind leaves the builder's default; a zero bias leaves LootBias unset.
|
||||
type pgNodeOverride struct {
|
||||
kind ZoneNodeKind
|
||||
bias float64
|
||||
}
|
||||
|
||||
// pgFork is a binary fork: a free (LockNone) 3-spur and a locked 3-spur,
|
||||
// both converging on the region's gate node.
|
||||
type pgFork struct {
|
||||
label string
|
||||
freeLabel [3]string
|
||||
lockLabel [3]string
|
||||
lock pgLock
|
||||
}
|
||||
|
||||
// pgCapstone is the ternary fork3: one free spur and two independently
|
||||
// locked spurs, all converging on the merge.
|
||||
type pgCapstone struct {
|
||||
label string
|
||||
freeLabel [3]string
|
||||
lockALabel [3]string
|
||||
lockA pgLock
|
||||
lockBLabel [3]string
|
||||
lockB pgLock
|
||||
}
|
||||
|
||||
type pgZoneSpec struct {
|
||||
zoneID ZoneID
|
||||
regions [4]string
|
||||
entry string
|
||||
preamble [12]string
|
||||
fork1 pgFork
|
||||
r2gate string
|
||||
r2build [6]string
|
||||
fork2 pgFork
|
||||
r3gate string
|
||||
r3build [6]string
|
||||
capstone pgCapstone // fork3
|
||||
merge string
|
||||
approach [5]string
|
||||
boss string
|
||||
overrides map[string]pgNodeOverride // keyed by node-id suffix (no zone prefix)
|
||||
}
|
||||
|
||||
// buildPostgameZoneGraph assembles and validates the canonical T6 graph.
|
||||
func buildPostgameZoneGraph(s pgZoneSpec) ZoneGraph {
|
||||
zid := string(s.zoneID)
|
||||
// Baseline anchors so every T6 zone carries a mid-run hazard and two
|
||||
// region-guardian elites without repeating them in each spec. Spec
|
||||
// overrides win (applied after).
|
||||
defaults := map[string]ZoneNodeKind{
|
||||
"p7": NodeKindTrap,
|
||||
"r2b6": NodeKindElite,
|
||||
"r3b6": NodeKindElite,
|
||||
}
|
||||
|
||||
var nodes []ZoneNode
|
||||
var edges []ZoneEdge
|
||||
id := func(suffix string) string { return zid + "." + suffix }
|
||||
|
||||
addNode := func(suffix, label, region string, kind ZoneNodeKind, x, y int) {
|
||||
if d, ok := defaults[suffix]; ok {
|
||||
kind = d
|
||||
}
|
||||
n := ZoneNode{
|
||||
NodeID: id(suffix), Kind: kind, RegionID: region,
|
||||
Label: label, PosX: x, PosY: y,
|
||||
}
|
||||
if ov, ok := s.overrides[suffix]; ok {
|
||||
if ov.kind != "" {
|
||||
n.Kind = ov.kind
|
||||
}
|
||||
if ov.bias != 0 {
|
||||
n.Content.LootBias = ov.bias
|
||||
}
|
||||
}
|
||||
if n.Kind == NodeKindEntry {
|
||||
n.IsEntry = true
|
||||
}
|
||||
if n.Kind == NodeKindBoss {
|
||||
n.IsBoss = true
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
addEdge := func(from, to string, lock pgLock, weight int) {
|
||||
e := ZoneEdge{From: id(from), To: id(to), Weight: weight, Lock: LockNone}
|
||||
if lock.kind != "" {
|
||||
e.Lock = lock.kind
|
||||
e.LockData = lock.lockData
|
||||
e.Hint = lock.hint
|
||||
}
|
||||
edges = append(edges, e)
|
||||
}
|
||||
// spur wires forkSuf → three spur nodes → gateSuf, locking only the
|
||||
// fork→spur[0] entry edge. The SECRET/LootBias node in a locked spur is
|
||||
// its middle node (index 1), reached by a LockNone edge — matching the
|
||||
// established "gated threshold, then unlocked entry into the secret"
|
||||
// pattern (see zone_graph_abyss_portal.go reality_seam).
|
||||
spur := func(forkSuf string, labels [3]string, lock pgLock, gateSuf, region, sufPrefix string, xStart, y, weight int) {
|
||||
prev := forkSuf
|
||||
for i := 0; i < 3; i++ {
|
||||
suf := fmt.Sprintf("%s%d", sufPrefix, i+1)
|
||||
addNode(suf, labels[i], region, NodeKindExploration, xStart+i, y)
|
||||
lk := pgLock{}
|
||||
if i == 0 {
|
||||
lk = lock
|
||||
}
|
||||
addEdge(prev, suf, lk, weight)
|
||||
prev = suf
|
||||
}
|
||||
addEdge(prev, gateSuf, pgLock{}, 1)
|
||||
}
|
||||
|
||||
r1, r2, r3, r4 := s.regions[0], s.regions[1], s.regions[2], s.regions[3]
|
||||
|
||||
// R1 preamble.
|
||||
addNode("entry", s.entry, r1, NodeKindEntry, 0, 2)
|
||||
prev := "entry"
|
||||
for i, lab := range s.preamble {
|
||||
suf := fmt.Sprintf("p%d", i+1)
|
||||
addNode(suf, lab, r1, NodeKindExploration, i+1, 2)
|
||||
addEdge(prev, suf, pgLock{}, 1)
|
||||
prev = suf
|
||||
}
|
||||
addNode("fork1", s.fork1.label, r1, NodeKindFork, 13, 2)
|
||||
addEdge(prev, "fork1", pgLock{}, 1)
|
||||
|
||||
// R2: fork1 spurs → r2gate → buildup → fork2.
|
||||
addNode("r2gate", s.r2gate, r2, NodeKindExploration, 17, 2)
|
||||
spur("fork1", s.fork1.freeLabel, pgLock{}, "r2gate", r2, "f1a", 14, 1, 1)
|
||||
spur("fork1", s.fork1.lockLabel, s.fork1.lock, "r2gate", r2, "f1b", 14, 3, 2)
|
||||
prev = "r2gate"
|
||||
for i, lab := range s.r2build {
|
||||
suf := fmt.Sprintf("r2b%d", i+1)
|
||||
addNode(suf, lab, r2, NodeKindExploration, 18+i, 2)
|
||||
addEdge(prev, suf, pgLock{}, 1)
|
||||
prev = suf
|
||||
}
|
||||
addNode("fork2", s.fork2.label, r2, NodeKindFork, 24, 2)
|
||||
addEdge(prev, "fork2", pgLock{}, 1)
|
||||
|
||||
// R3: fork2 spurs → r3gate → buildup → fork3.
|
||||
addNode("r3gate", s.r3gate, r3, NodeKindExploration, 28, 2)
|
||||
spur("fork2", s.fork2.freeLabel, pgLock{}, "r3gate", r3, "f2a", 25, 1, 1)
|
||||
spur("fork2", s.fork2.lockLabel, s.fork2.lock, "r3gate", r3, "f2b", 25, 3, 2)
|
||||
prev = "r3gate"
|
||||
for i, lab := range s.r3build {
|
||||
suf := fmt.Sprintf("r3b%d", i+1)
|
||||
addNode(suf, lab, r3, NodeKindExploration, 29+i, 2)
|
||||
addEdge(prev, suf, pgLock{}, 1)
|
||||
prev = suf
|
||||
}
|
||||
addNode("fork3", s.capstone.label, r3, NodeKindFork, 35, 2)
|
||||
addEdge(prev, "fork3", pgLock{}, 1)
|
||||
|
||||
// R4: fork3 ternary capstone → merge → approach → boss.
|
||||
addNode("merge", s.merge, r4, NodeKindMerge, 39, 2)
|
||||
spur("fork3", s.capstone.freeLabel, pgLock{}, "merge", r4, "cap1", 36, 1, 1)
|
||||
spur("fork3", s.capstone.lockALabel, s.capstone.lockA, "merge", r4, "cap2", 36, 2, 2)
|
||||
spur("fork3", s.capstone.lockBLabel, s.capstone.lockB, "merge", r4, "cap3", 36, 3, 3)
|
||||
prev = "merge"
|
||||
for i, lab := range s.approach {
|
||||
suf := fmt.Sprintf("ap%d", i+1)
|
||||
addNode(suf, lab, r4, NodeKindExploration, 40+i, 2)
|
||||
addEdge(prev, suf, pgLock{}, 1)
|
||||
prev = suf
|
||||
}
|
||||
addNode("boss", s.boss, r4, NodeKindBoss, 45, 2)
|
||||
addEdge(prev, "boss", pgLock{}, 1)
|
||||
|
||||
return buildPostgameGraphFrom(s.zoneID, nodes, edges)
|
||||
}
|
||||
|
||||
// buildPostgameGraphFrom is a thin seam over BuildGraph so tests can build
|
||||
// a spec's graph without re-registering it.
|
||||
func buildPostgameGraphFrom(zoneID ZoneID, nodes []ZoneNode, edges []ZoneEdge) ZoneGraph {
|
||||
return BuildGraph(zoneID, nodes, edges)
|
||||
}
|
||||
79
internal/plugin/postgame_zone_graph_test.go
Normal file
79
internal/plugin/postgame_zone_graph_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// assertPostgameGraph runs the invariants every T6 zone graph must satisfy:
|
||||
// registered, longest walk in the [44,52] band, all four authored regions
|
||||
// present and matching the ExpeditionRegion registry, every fork keeps a
|
||||
// free (LockNone) exit, and all three capstone leaves reach the boss.
|
||||
func assertPostgameGraph(t *testing.T, zoneID ZoneID, build func() ZoneGraph) {
|
||||
t.Helper()
|
||||
|
||||
if _, ok := zoneGraphRegistry[zoneID]; !ok {
|
||||
t.Fatalf("%s graph not registered", zoneID)
|
||||
}
|
||||
g := build()
|
||||
|
||||
if got := graphLongestPath(g); got < 44 || got > 52 {
|
||||
t.Errorf("%s longest path = %d, want T6 band [44,52]", zoneID, got)
|
||||
}
|
||||
|
||||
// Regions: every node has a RegionID drawn from the zone's registry,
|
||||
// and every registered region has at least one node.
|
||||
valid := map[string]bool{}
|
||||
for _, r := range regionsByZone[zoneID] {
|
||||
valid[r.ID] = true
|
||||
}
|
||||
if len(valid) != 4 {
|
||||
t.Fatalf("%s: expected 4 registered regions, got %d", zoneID, len(valid))
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for id, n := range g.Nodes {
|
||||
if n.RegionID == "" {
|
||||
t.Errorf("%s node %s has empty RegionID", zoneID, id)
|
||||
continue
|
||||
}
|
||||
if !valid[n.RegionID] {
|
||||
t.Errorf("%s node %s RegionID %q not in region registry", zoneID, id, n.RegionID)
|
||||
}
|
||||
seen[n.RegionID]++
|
||||
}
|
||||
for r := range valid {
|
||||
if seen[r] == 0 {
|
||||
t.Errorf("%s region %q has no nodes", zoneID, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Every fork offers at least one LockNone exit (local mirror of the
|
||||
// global TestZoneGraphs_NoSoftLockedFork, kept here so a broken spec
|
||||
// fails in its own zone's test too).
|
||||
for id, n := range g.Nodes {
|
||||
if n.Kind != NodeKindFork {
|
||||
continue
|
||||
}
|
||||
free := false
|
||||
for _, e := range g.outgoingEdges(id) {
|
||||
if e.Lock == LockNone || e.Lock == "" {
|
||||
free = true
|
||||
}
|
||||
}
|
||||
if !free {
|
||||
t.Errorf("%s fork %s: no free exit (soft-lock)", zoneID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// All three capstone leaves reach the boss.
|
||||
for _, leaf := range []string{
|
||||
string(zoneID) + ".cap13",
|
||||
string(zoneID) + ".cap23",
|
||||
string(zoneID) + ".cap33",
|
||||
} {
|
||||
if _, ok := g.Nodes[leaf]; !ok {
|
||||
t.Errorf("%s missing capstone leaf %s", zoneID, leaf)
|
||||
continue
|
||||
}
|
||||
if !reachable(g, leaf, g.Boss) {
|
||||
t.Errorf("%s capstone leaf %s unreachable to boss", zoneID, leaf)
|
||||
}
|
||||
}
|
||||
}
|
||||
89
internal/plugin/postgame_zones.go
Normal file
89
internal/plugin/postgame_zones.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package plugin
|
||||
|
||||
// Tier 6 "Mythic" post-game zones — shared plumbing (Phase P1).
|
||||
//
|
||||
// Post-game means earned, not leveled. zonesForLevel can't gate these
|
||||
// (playerTier = (L-1)/3+1 plus the +2 lookahead would surface a T6 zone far
|
||||
// too early — and at L18 the lookahead reaches T6 outright), so entry is
|
||||
// gated by postgameUnlocked: both Tier 5 zone bosses beaten AND level ≥ 18.
|
||||
// Locked zones still render in `!zone list` under a "Beyond the Map" divider
|
||||
// with a hint line — visible aspiration, gated entry.
|
||||
//
|
||||
// See gogobee_postgame_zones_plan.md.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// postgameLevelFloor — the minimum character level for T6 entry. L18 lets
|
||||
// near-cap players scout and die educationally before hitting L20.
|
||||
const postgameLevelFloor = 18
|
||||
|
||||
// postgameZones returns every registered Tier 6 zone in declared order.
|
||||
func postgameZones() []ZoneDefinition {
|
||||
var out []ZoneDefinition
|
||||
for _, z := range allZones() {
|
||||
if z.Tier >= ZoneTierMythic {
|
||||
out = append(out, z)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isPostgameZone reports whether a zone id is a Tier 6 post-game zone.
|
||||
func isPostgameZone(id ZoneID) bool {
|
||||
z, ok := getZone(id)
|
||||
return ok && z.Tier >= ZoneTierMythic
|
||||
}
|
||||
|
||||
// postgameUnlocked reports whether a player may enter Tier 6 zones, and if
|
||||
// not, a one-line player-facing reason. The two gates:
|
||||
// - both Tier 5 zone bosses (dragons_lair AND abyss_portal) beaten, and
|
||||
// - character level ≥ postgameLevelFloor.
|
||||
//
|
||||
// The T5-clear check reuses clearedZoneIDs (boss_defeated DISTINCT query),
|
||||
// so an extraction that merely ended in 'complete' does not count.
|
||||
func postgameUnlocked(userID id.UserID, level int) (bool, string) {
|
||||
if level < postgameLevelFloor {
|
||||
return false, fmt.Sprintf(
|
||||
"The way past the map's edge opens at level %d. You're level %d.",
|
||||
postgameLevelFloor, level)
|
||||
}
|
||||
cleared := clearedZoneIDs(db.Get(), userID)
|
||||
if !cleared[ZoneDragonsLair] || !cleared[ZoneAbyssPortal] {
|
||||
return false, "The way past the map's edge stays shut until both " +
|
||||
"Infernax and Belaxath lie dead — the dragon and the balor, " +
|
||||
"the two ends of the known world."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// availableZonesFor returns the zones a player may enter for command
|
||||
// resolution: the normal level-gated set, plus the Tier 6 post-game zones
|
||||
// when postgameUnlocked. Command sites use this in place of a bare
|
||||
// zonesForLevel so an unlocked player can resolve a T6 zone id/index.
|
||||
func availableZonesFor(userID id.UserID, level int) []ZoneDefinition {
|
||||
out := zonesForLevel(level)
|
||||
if ok, _ := postgameUnlocked(userID, level); ok {
|
||||
out = append(out, postgameZones()...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// postgameLockReason returns a player-facing lock reason when input names a
|
||||
// Tier 6 zone the player has NOT unlocked, otherwise "". Lets command sites
|
||||
// answer "you're not ready" instead of a generic "unknown zone" when a
|
||||
// player types a locked post-game zone by name/id.
|
||||
func postgameLockReason(input string, userID id.UserID, level int) string {
|
||||
if _, ok := resolveZoneInput(input, postgameZones()); !ok {
|
||||
return ""
|
||||
}
|
||||
if unlocked, reason := postgameUnlocked(userID, level); !unlocked {
|
||||
return reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
115
internal/plugin/sim_seed.go
Normal file
115
internal/plugin/sim_seed.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Deterministic sim seeding — OFF by default, so the production binary is
|
||||
// byte-identical in behaviour. The expedition-sim harness calls SeedSim once at
|
||||
// subprocess startup to make a run reproducible: the three nondeterminism seams
|
||||
// that dominate outcome variance — the zone-layout RNG, the run id (traps hash
|
||||
// off it), and each combat SessionID (the whole turn engine hashes from it) —
|
||||
// all derive from a single base seed via a process-local counter.
|
||||
//
|
||||
// This does NOT touch the peripheral top-level math/rand/v2 procs (pardon rolls,
|
||||
// minor damage jitter, ambient events); those stay random. Seeding the three
|
||||
// dominant seams collapses the batch-to-batch drift (identical dungeons + combat
|
||||
// dice across arms) so an A/B passive change reads at ~0 noise. If a residual
|
||||
// proc ever proves load-bearing, seed it too — but validate empirically first.
|
||||
//
|
||||
// Ordering contract: within one subprocess an expedition is resolved on a single
|
||||
// goroutine, so nextSimSeed() is drawn in a deterministic order (zone rng, run
|
||||
// id, then one per combat session in creation order). Two arms sharing a base
|
||||
// seed draw identical values up to the point a passive change diverges them —
|
||||
// and SessionIDs are assigned at session *creation*, before a fight resolves, so
|
||||
// the Nth combat pairs regardless of how the fight plays out.
|
||||
var (
|
||||
simSeedActive atomic.Bool
|
||||
simSeedBase uint64
|
||||
simSeedCtr atomic.Uint64
|
||||
)
|
||||
|
||||
// simCombatRand is an INDEPENDENT seeded stream for the outcome-decisive
|
||||
// in-combat rolls that the three dominant seams don't cover: the spell
|
||||
// attack/save/damage d20s (dnd_spell_combat.go), the 33% pardon death-cheat
|
||||
// (combat_bridge.go), and the short-rest heal die (dnd_rest.go). These fire
|
||||
// disproportionately in caster / borderline-boss runs — exactly the population
|
||||
// being certified — and leaving them on the global generator was the main
|
||||
// source of the per-cell residual (the fighter+ranger repro never exercised
|
||||
// them, so it read ~0 noise while bard swung 8pp on identical code).
|
||||
//
|
||||
// It is a SEPARATE stream from nextSimSeed()'s counter so it never perturbs the
|
||||
// zone-layout / run-id / SessionID draw order the martial repro validated.
|
||||
// Within a subprocess an expedition runs on one goroutine; the mutex is belt-
|
||||
// and-braces so a stray concurrent draw can't race, not a correctness crutch.
|
||||
var (
|
||||
simCombatMu sync.Mutex
|
||||
simCombatRand *rand.Rand
|
||||
)
|
||||
|
||||
// SeedSim activates deterministic seeding for this process. A negative seed
|
||||
// disables it (the default). Call once at startup, before any expedition runs —
|
||||
// it is not safe to toggle while a run is in flight.
|
||||
func SeedSim(seed int64) {
|
||||
if seed < 0 {
|
||||
simSeedActive.Store(false)
|
||||
simCombatRand = nil
|
||||
return
|
||||
}
|
||||
simSeedBase = uint64(seed)
|
||||
simSeedCtr.Store(0)
|
||||
// 0x5EED5 gives the peripheral-combat stream a distinct sub-stream from the
|
||||
// seam counter so the two never correlate or share draws.
|
||||
simCombatRand = rand.New(rand.NewPCG(uint64(seed), 0x5EED5))
|
||||
simSeedActive.Store(true)
|
||||
}
|
||||
|
||||
func simSeedOn() bool { return simSeedActive.Load() }
|
||||
|
||||
// nextSimSeed returns the next counter-mixed seed. Golden-ratio odd multiplier
|
||||
// decorrelates successive draws.
|
||||
func nextSimSeed() uint64 {
|
||||
n := simSeedCtr.Add(1)
|
||||
return simSeedBase ^ (n * 0x9E3779B97F4A7C15)
|
||||
}
|
||||
|
||||
// simHexToken renders one seeded draw as the same 16-char hex shape the
|
||||
// crypto-random id helpers produce.
|
||||
func simHexToken() string {
|
||||
v := nextSimSeed()
|
||||
var b [8]byte
|
||||
for i := range b {
|
||||
b[i] = byte(v >> (8 * i))
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// simZoneRNG returns a deterministic generator for one zone layout.
|
||||
func simZoneRNG() *rand.Rand {
|
||||
return rand.New(rand.NewPCG(nextSimSeed(), 0xC0FFEE))
|
||||
}
|
||||
|
||||
// simIntN / simFloat64 draw an outcome-decisive in-combat roll from the seeded
|
||||
// peripheral stream when seeding is active; otherwise they fall through to the
|
||||
// global generator so the production binary stays byte-identical (prod never
|
||||
// calls SeedSim, so simSeedOn() is always false there).
|
||||
func simIntN(n int) int {
|
||||
if !simSeedOn() {
|
||||
return rand.IntN(n)
|
||||
}
|
||||
simCombatMu.Lock()
|
||||
defer simCombatMu.Unlock()
|
||||
return simCombatRand.IntN(n)
|
||||
}
|
||||
|
||||
func simFloat64() float64 {
|
||||
if !simSeedOn() {
|
||||
return rand.Float64()
|
||||
}
|
||||
simCombatMu.Lock()
|
||||
defer simCombatMu.Unlock()
|
||||
return simCombatRand.Float64()
|
||||
}
|
||||
75
internal/plugin/zone_graph_drowned_star.go
Normal file
75
internal/plugin/zone_graph_drowned_star.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package plugin
|
||||
|
||||
// The Drowned Star branching graph (Tier 6, Phase P4).
|
||||
//
|
||||
// The abyssal trench where Seraphel rode her star down. Regions: The Long
|
||||
// Sink → Pilgrim Trench → The Radiant Wreck → The Heart Chapel, on the
|
||||
// shared T6 skeleton. Longest walk = 46, in band.
|
||||
//
|
||||
// A descent zone: the forks gate on holding your nerve in the dark (CON
|
||||
// against the pressure, WIS against the false lights, CHA to walk the
|
||||
// pilgrim route unmolested). The locked spurs are radiant pockets of the
|
||||
// wreck — high LootBias, lit by the dying star.
|
||||
|
||||
func zoneDrownedStarGraph() ZoneGraph {
|
||||
stat := func(s string, dc int, hint string) pgLock {
|
||||
return pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": s, "dc": dc}, hint: hint}
|
||||
}
|
||||
perc := func(dc int, hint string) pgLock {
|
||||
return pgLock{kind: LockPerception, lockData: map[string]any{"dc": dc}, hint: hint}
|
||||
}
|
||||
return buildPostgameZoneGraph(pgZoneSpec{
|
||||
zoneID: ZoneDrownedStar,
|
||||
regions: [4]string{"star_long_sink", "star_pilgrim_trench", "star_radiant_wreck", "star_heart_chapel"},
|
||||
entry: "The Waterline",
|
||||
preamble: [12]string{
|
||||
"The Long Sink", "Descending Dark", "Pressure Gate", "The Static Shoal",
|
||||
"Choir of Static", "Sunless Landing", "Undertow Snare", "The Glow Below",
|
||||
"Pilgrim's Approach", "Trench Mouth", "The Lit Road", "First Lantern",
|
||||
},
|
||||
fork1: pgFork{
|
||||
label: "The First Descent",
|
||||
freeLabel: [3]string{"Marked Pilgrim Road", "Lantern-Lit Walk", "Trench Floor"},
|
||||
lockLabel: [3]string{"Off-Route Pocket", "The Radiant Wreck-Pocket", "Sunken Alcove"},
|
||||
lock: stat("CON", 16, "the pressure here would fold a lesser diver — endure it and the wreck opens"),
|
||||
},
|
||||
r2gate: "Pilgrim Trench",
|
||||
r2build: [6]string{
|
||||
"Trench of the Followed Light", "The Angler-Knight's Walk", "False-Light Gallery",
|
||||
"Warden's Lure", "The Unseen-Again Hall", "Wreck Threshold",
|
||||
},
|
||||
fork2: pgFork{
|
||||
label: "The Second Descent",
|
||||
freeLabel: [3]string{"Steady Pilgrim Line", "Trusted Dark", "Wreck Floor"},
|
||||
lockLabel: [3]string{"Halo-Fragment Recess", "The Star-Lit Vault", "Radiant Niche"},
|
||||
lock: stat("WIS", 17, "one lantern is not a lure — know which and it leads true"),
|
||||
},
|
||||
r3gate: "The Radiant Wreck",
|
||||
r3build: [6]string{
|
||||
"The Star's Broken Hull", "Gallery of Ten Thousand Years", "Leviathanling Roost",
|
||||
"The Glowing Silhouette", "Chapel Antechamber", "Threshold of the Heart",
|
||||
},
|
||||
capstone: pgCapstone{
|
||||
label: "The Chapel Fork",
|
||||
freeLabel: [3]string{"Direct Chapel-Walk", "The Open Reredos", "Chapel Floor"},
|
||||
lockALabel: [3]string{"Pilgrim's Grace", "The Unmolested Route", "Sanctified Path"},
|
||||
lockA: stat("CHA", 18, "walk the pilgrim route as a pilgrim and it lets you pass"),
|
||||
lockBLabel: [3]string{"Halo-Seam", "The Heart's Own Light", "Radiant Shortcut"},
|
||||
lockB: perc(18, "a crack in the radiance where the star still leaks its oldest light"),
|
||||
},
|
||||
merge: "The Heart Chapel",
|
||||
approach: [5]string{
|
||||
"The Shielded Star", "Seraphel's Vigil", "Sanctified Undertow Walk", "The Grieving Light", "The Last Radiance",
|
||||
},
|
||||
boss: "Seraphel, the Light That Sank",
|
||||
overrides: map[string]pgNodeOverride{
|
||||
"f1b2": {kind: NodeKindSecret, bias: 2.25},
|
||||
"f2b2": {kind: NodeKindSecret, bias: 2.25},
|
||||
"cap32": {kind: NodeKindSecret, bias: 2.5},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerZoneGraph(zoneDrownedStarGraph())
|
||||
}
|
||||
25
internal/plugin/zone_graph_drowned_star_test.go
Normal file
25
internal/plugin/zone_graph_drowned_star_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDrownedStarGraph_Invariants(t *testing.T) {
|
||||
assertPostgameGraph(t, ZoneDrownedStar, zoneDrownedStarGraph)
|
||||
}
|
||||
|
||||
// TestDrownedStarGraph_PilgrimGrace verifies the CHA pilgrim-route capstone
|
||||
// spur and the radiant secret pockets are authored.
|
||||
func TestDrownedStarGraph_PilgrimGrace(t *testing.T) {
|
||||
g := zoneDrownedStarGraph()
|
||||
if got := countSecretNodes(g); got != 3 {
|
||||
t.Errorf("secret nodes = %d, want 3", got)
|
||||
}
|
||||
chaSpur := false
|
||||
for _, e := range g.outgoingEdges("drowned_star.fork3") {
|
||||
if e.Lock == LockStatCheck && lockDataString(e.LockData, "stat") == "CHA" {
|
||||
chaSpur = true
|
||||
}
|
||||
}
|
||||
if !chaSpur {
|
||||
t.Error("Drowned Star capstone should offer a CHA pilgrim-route spur")
|
||||
}
|
||||
}
|
||||
75
internal/plugin/zone_graph_first_hoard.go
Normal file
75
internal/plugin/zone_graph_first_hoard.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package plugin
|
||||
|
||||
// The First Hoard branching graph (Tier 6, Phase P4).
|
||||
//
|
||||
// Aurvandryx's gilded clutch under Infernus Peak. Regions: The Cooling
|
||||
// Throne → Gilded Gullet → The Clutch Vaults → Cradle of the First Flame,
|
||||
// on the shared T6 skeleton. Longest walk = 46, in band.
|
||||
//
|
||||
// Signature set-piece: the game-max LootBias route. Per the plan, the First
|
||||
// Hoard carries the richest nodes in the game (2.5–3.0) because the boss's
|
||||
// Layer-2 Greed Tax makes rich routes self-balancing — gild yourself and
|
||||
// pay for it at Aurvandryx, or take the lean line. The free capstone (the
|
||||
// deepest gilded vein, cap12) sits at the 3.0 ceiling; two buildup nodes
|
||||
// carry 2.5/2.75. Forks gate on physical checks (STR/CON) — you force the
|
||||
// vault, you don't sneak it.
|
||||
|
||||
func zoneFirstHoardGraph() ZoneGraph {
|
||||
stat := func(s string, dc int, hint string) pgLock {
|
||||
return pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": s, "dc": dc}, hint: hint}
|
||||
}
|
||||
return buildPostgameZoneGraph(pgZoneSpec{
|
||||
zoneID: ZoneFirstHoard,
|
||||
regions: [4]string{"hoard_cooling_throne", "hoard_gilded_gullet", "hoard_clutch_vaults", "hoard_cradle_first_flame"},
|
||||
entry: "The Still-Warm Gate",
|
||||
preamble: [12]string{
|
||||
"Infernax's Corpse-Terrain", "The Cooling Throne", "Scale-River Ford", "Ashfall Landing",
|
||||
"The Guard-Dog's Rest", "Ember Colonnade", "Slag Pit", "Choir-Drake Roost",
|
||||
"The Gilded Descent", "Minted Approach", "Coin-Drift Hall", "Throat of the Mountain",
|
||||
},
|
||||
fork1: pgFork{
|
||||
label: "The First Swallow",
|
||||
freeLabel: [3]string{"Lean Passage", "Bare Gullet", "Gullet Floor"},
|
||||
lockLabel: [3]string{"Vault-Seam", "The Gilded Vein", "Coin-Choked Stair"},
|
||||
lock: stat("STR", 16, "a wall of fused coin — force it and the hoard spills"),
|
||||
},
|
||||
r2gate: "Gilded Gullet",
|
||||
r2build: [6]string{
|
||||
"Hall of Minted Antibodies", "The Simulacrum Font", "Mirror-Mint Gallery",
|
||||
"Executor's Approach", "The Scale-Debt Walk", "Vault Threshold",
|
||||
},
|
||||
fork2: pgFork{
|
||||
label: "The Second Swallow",
|
||||
freeLabel: [3]string{"Poverty's Line", "Empty Clutch-Walk", "Clutch Floor"},
|
||||
lockLabel: [3]string{"Cracked Egg-Vault", "The Gilded Reliquary", "Gold-Sealed Nest"},
|
||||
lock: stat("CON", 16, "the heat here would cook a lesser body — endure it for the richest vault"),
|
||||
},
|
||||
r3gate: "The Clutch Vaults",
|
||||
r3build: [6]string{
|
||||
"Rows of Gilded Eggs", "The Resonance Nursery", "Choir-Cracked Stone",
|
||||
"Wyrm-Sworn Landing", "Cradle Antechamber", "Threshold of the First Flame",
|
||||
},
|
||||
capstone: pgCapstone{
|
||||
label: "The Cradle Fork",
|
||||
freeLabel: [3]string{"The Deep Vein", "Mother-Lode Gallery", "Cradle Floor"},
|
||||
lockALabel: [3]string{"Ember Squeeze", "The Molten Reliquary", "Scale-Sworn Path"},
|
||||
lockA: stat("STR", 18, "prise the last vault open before she wakes"),
|
||||
lockBLabel: [3]string{"Heat-Shimmer Recess", "The Poverty Shrine", "Vowed Approach"},
|
||||
lockB: stat("WIS", 18, "leave the gold — the lean route arrives unburdened"),
|
||||
},
|
||||
merge: "Cradle of the First Flame",
|
||||
approach: [5]string{
|
||||
"The Warming Nest", "Clutch of the First Wyrm", "Ember-Before-Fire Walk", "The Progenitor's Shadow", "The First Breath",
|
||||
},
|
||||
boss: "Aurvandryx, the Ember Before Fire",
|
||||
overrides: map[string]pgNodeOverride{
|
||||
"cap12": {bias: 3.0}, // deepest gilded vein — game-max
|
||||
"f1b2": {bias: 2.75}, // The Gilded Vein
|
||||
"r2b2": {bias: 2.5}, // The Simulacrum Font
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerZoneGraph(zoneFirstHoardGraph())
|
||||
}
|
||||
32
internal/plugin/zone_graph_first_hoard_test.go
Normal file
32
internal/plugin/zone_graph_first_hoard_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFirstHoardGraph_Invariants(t *testing.T) {
|
||||
assertPostgameGraph(t, ZoneFirstHoard, zoneFirstHoardGraph)
|
||||
}
|
||||
|
||||
// TestFirstHoardGraph_GameMaxLootBias verifies the plan's requirement that
|
||||
// the First Hoard carries the game-max LootBias route (2.5–3.0), which the
|
||||
// boss's Greed Tax self-balances. The deepest gilded vein hits the 3.0
|
||||
// ceiling and at least three nodes sit at or above 2.5.
|
||||
func TestFirstHoardGraph_GameMaxLootBias(t *testing.T) {
|
||||
g := zoneFirstHoardGraph()
|
||||
|
||||
var max float64
|
||||
rich := 0
|
||||
for _, n := range g.Nodes {
|
||||
if n.Content.LootBias > max {
|
||||
max = n.Content.LootBias
|
||||
}
|
||||
if n.Content.LootBias >= 2.5 {
|
||||
rich++
|
||||
}
|
||||
}
|
||||
if max < 3.0 {
|
||||
t.Errorf("max LootBias = %v, want >= 3.0 (game-max gilded route)", max)
|
||||
}
|
||||
if rich < 3 {
|
||||
t.Errorf("nodes with LootBias >= 2.5 = %d, want >= 3", rich)
|
||||
}
|
||||
}
|
||||
75
internal/plugin/zone_graph_last_meridian.go
Normal file
75
internal/plugin/zone_graph_last_meridian.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package plugin
|
||||
|
||||
// The Last Meridian branching graph (Tier 6, Phase P4).
|
||||
//
|
||||
// A necropolis-observatory being decommissioned hour by hour. Regions: The
|
||||
// Dusk Colonnade → Gallery of Spent Hours → The Escapement → One Minute To
|
||||
// Midnight, on the shared T6 skeleton. Longest walk = 46, in band.
|
||||
//
|
||||
// A time zone: the forks gate on reading the clockwork's rhythm (INT for the
|
||||
// escapement's rule, DEX to slip a stolen turn, WIS to keep your own hour
|
||||
// when the Hour Thief reaches for it). The locked spurs are pockets of
|
||||
// unspent time — high LootBias hours the Custodian hasn't dismantled yet.
|
||||
|
||||
func zoneLastMeridianGraph() ZoneGraph {
|
||||
stat := func(s string, dc int, hint string) pgLock {
|
||||
return pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": s, "dc": dc}, hint: hint}
|
||||
}
|
||||
perc := func(dc int, hint string) pgLock {
|
||||
return pgLock{kind: LockPerception, lockData: map[string]any{"dc": dc}, hint: hint}
|
||||
}
|
||||
return buildPostgameZoneGraph(pgZoneSpec{
|
||||
zoneID: ZoneLastMeridian,
|
||||
regions: [4]string{"meridian_dusk_colonnade", "meridian_gallery_spent_hours", "meridian_escapement", "meridian_one_minute"},
|
||||
entry: "The Dusk Gate",
|
||||
preamble: [12]string{
|
||||
"The Dusk Colonnade", "Hall of Winding Nothing", "The Hour Thief's Trail", "Candle-Shortened Walk",
|
||||
"Horologist's Ghost-Aisle", "The Stolen Present", "Pickpocket's Snare", "Colonnade of Spent Light",
|
||||
"The Gallery Approach", "Gallery Mouth", "Hall of Spent Hours", "First Struck Bell",
|
||||
},
|
||||
fork1: pgFork{
|
||||
label: "The First Hour",
|
||||
freeLabel: [3]string{"On-Time Passage", "The Kept Hour", "Gallery Floor"},
|
||||
lockLabel: [3]string{"Guarded Recess", "The Unspent Hour-Pocket", "Ticking Alcove"},
|
||||
lock: stat("WIS", 16, "keep your own hour when the Thief reaches for it and a door stays open"),
|
||||
},
|
||||
r2gate: "Gallery of Spent Hours",
|
||||
r2build: [6]string{
|
||||
"Rows of Stopped Clocks", "The Twin Wardens' Door", "Changing of the Guard",
|
||||
"Shift-Schedule Hall", "The Nothing Behind", "Escapement Threshold",
|
||||
},
|
||||
fork2: pgFork{
|
||||
label: "The Second Hour",
|
||||
freeLabel: [3]string{"Steady Escapement Walk", "The Even-Round Hall", "Escapement Floor"},
|
||||
lockLabel: [3]string{"Gear-Seam", "The Unwound Vault", "Pendulum Niche"},
|
||||
lock: stat("INT", 17, "the pendulum only swings on even rounds — read the rhythm and slip past"),
|
||||
},
|
||||
r3gate: "The Escapement",
|
||||
r3build: [6]string{
|
||||
"The Original Pendulum", "Gallery of Its Legs", "Verdigris Colossus Walk",
|
||||
"Orrery-Ring Landing", "Midnight Antechamber", "Threshold of the Last Hour",
|
||||
},
|
||||
capstone: pgCapstone{
|
||||
label: "The Midnight Fork",
|
||||
freeLabel: [3]string{"Direct Midnight-Walk", "The Open Escapement", "Midnight Floor"},
|
||||
lockALabel: [3]string{"Borrowed Second", "The Slipped Turn", "Stolen-Time Path"},
|
||||
lockA: stat("DEX", 18, "steal a second from the clock and step through before it notices"),
|
||||
lockBLabel: [3]string{"Unwound Seam", "The Hour Never Struck", "Unspent Shortcut"},
|
||||
lockB: perc(18, "one hour on the great face was never dismantled — it is still open"),
|
||||
},
|
||||
merge: "One Minute To Midnight",
|
||||
approach: [5]string{
|
||||
"The Apologizing Golem", "Orrery of the Last Hour", "Chime Approach", "The Rewinding Second", "The Clock Stops",
|
||||
},
|
||||
boss: "The Custodian of the Last Hour",
|
||||
overrides: map[string]pgNodeOverride{
|
||||
"f1b2": {kind: NodeKindSecret, bias: 2.25},
|
||||
"f2b2": {kind: NodeKindSecret, bias: 2.25},
|
||||
"cap32": {kind: NodeKindSecret, bias: 2.5},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerZoneGraph(zoneLastMeridianGraph())
|
||||
}
|
||||
25
internal/plugin/zone_graph_last_meridian_test.go
Normal file
25
internal/plugin/zone_graph_last_meridian_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLastMeridianGraph_Invariants(t *testing.T) {
|
||||
assertPostgameGraph(t, ZoneLastMeridian, zoneLastMeridianGraph)
|
||||
}
|
||||
|
||||
// TestLastMeridianGraph_RhythmLocks verifies the escapement's rhythm gate
|
||||
// (INT on fork2) and the unspent-time secret pockets are authored.
|
||||
func TestLastMeridianGraph_RhythmLocks(t *testing.T) {
|
||||
g := zoneLastMeridianGraph()
|
||||
if got := countSecretNodes(g); got != 3 {
|
||||
t.Errorf("secret nodes = %d, want 3", got)
|
||||
}
|
||||
intFork := false
|
||||
for _, e := range g.outgoingEdges("last_meridian.fork2") {
|
||||
if e.Lock == LockStatCheck && lockDataString(e.LockData, "stat") == "INT" {
|
||||
intFork = true
|
||||
}
|
||||
}
|
||||
if !intFork {
|
||||
t.Error("Last Meridian fork2 should gate on INT (escapement rhythm)")
|
||||
}
|
||||
}
|
||||
75
internal/plugin/zone_graph_ossuary_ascendant.go
Normal file
75
internal/plugin/zone_graph_ossuary_ascendant.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package plugin
|
||||
|
||||
// The Ossuary Ascendant branching graph (Tier 6, Phase P4).
|
||||
//
|
||||
// Valdris's inverted bone-cathedral. Four regions — Bonefall Steps →
|
||||
// Cathedral of Marrow → Reliquary Vaults → The Apotheosis Engine — on the
|
||||
// shared T6 skeleton (buildPostgameZoneGraph). Longest walk = 46, in band.
|
||||
//
|
||||
// Signature set-piece: the three Phylactery Verses. Each is a NodeKindSecret
|
||||
// reached through a LockPerception spur (one per fork), matching the plan's
|
||||
// "three SECRET nodes, Verses found before the boss strip a Legendary
|
||||
// Resistance / rebirth proc." The gate rides the fork→threshold edge; the
|
||||
// edge into the Verse itself is unlocked. Full-clear explorers who find all
|
||||
// three fight a mortal; speedrunners fight a god. Secret suffixes: f1b2,
|
||||
// f2b2, cap32 (the middle node of each Perception spur).
|
||||
|
||||
func zoneOssuaryAscendantGraph() ZoneGraph {
|
||||
perc := func(dc int, hint string) pgLock {
|
||||
return pgLock{kind: LockPerception, lockData: map[string]any{"dc": dc}, hint: hint}
|
||||
}
|
||||
return buildPostgameZoneGraph(pgZoneSpec{
|
||||
zoneID: ZoneOssuaryAscendant,
|
||||
regions: [4]string{"ossuary_bonefall_steps", "ossuary_cathedral_marrow", "ossuary_reliquary_vaults", "ossuary_apotheosis_engine"},
|
||||
entry: "The Inverted Threshold",
|
||||
preamble: [12]string{
|
||||
"Bonefall Steps", "The Rising Ossuary", "Marrow Stair", "Chancel of Ash",
|
||||
"Ribvault Landing", "The Patient Gallery", "Reliquary Snare", "Choirless Nave",
|
||||
"Ascending Transept", "The Counted Dead", "Vertebral Climb", "First Landing",
|
||||
},
|
||||
fork1: pgFork{
|
||||
label: "The First Ascent",
|
||||
freeLabel: [3]string{"Open Stair", "Bonelit Passage", "Cathedral Approach"},
|
||||
lockLabel: [3]string{"Hairline Recess", "Verse of the Waiting Grave", "Sealed Alcove"},
|
||||
lock: perc(16, "a seam in the marrow where the wall was never quite finished"),
|
||||
},
|
||||
r2gate: "Cathedral of Marrow",
|
||||
r2build: [6]string{
|
||||
"Nave of Fused Clergy", "The Censer Walk", "Grave-Smoke Aisle",
|
||||
"Choir of the Pre-Blessed", "Cardinal's Approach", "The Marrow Altar",
|
||||
},
|
||||
fork2: pgFork{
|
||||
label: "The Second Ascent",
|
||||
freeLabel: [3]string{"Vault Stair", "Donor-Bone Hall", "Reliquary Approach"},
|
||||
lockLabel: [3]string{"Cracked Reliquary", "Verse of the Bait Long Set", "Dust-Sealed Niche"},
|
||||
lock: perc(17, "one reliquary is hollow where the others are full"),
|
||||
},
|
||||
r3gate: "Reliquary Vaults",
|
||||
r3build: [6]string{
|
||||
"The Coffin-Lid Wall", "Hall of Riveted Knights", "Gallery of the Crypt-Fallen",
|
||||
"The Unoccupied Armor", "Engine Antechamber", "Threshold of Ascension",
|
||||
},
|
||||
capstone: pgCapstone{
|
||||
label: "The Apotheosis Fork",
|
||||
freeLabel: [3]string{"Direct Ascent", "The Final Stair", "Engine Floor"},
|
||||
lockALabel: [3]string{"Deacon's Whisper", "The Named Dead", "Consecrated Path"},
|
||||
lockA: pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": "WIS", "dc": 18}, hint: "the choir sings your name in rounds — answer it and it opens"},
|
||||
lockBLabel: [3]string{"Vertebral Fault", "Verse of the Homecoming", "The Last Recess"},
|
||||
lockB: perc(18, "the highest vertebra of the cathedral was set wrong on purpose"),
|
||||
},
|
||||
merge: "The Apotheosis Engine",
|
||||
approach: [5]string{
|
||||
"Marrow Conduit", "The Ascending Choir", "Phylactery Cradle", "Vertebra of Godhood", "The Final Blessing",
|
||||
},
|
||||
boss: "Valdris, At Last",
|
||||
overrides: map[string]pgNodeOverride{
|
||||
"f1b2": {kind: NodeKindSecret, bias: 2.0},
|
||||
"f2b2": {kind: NodeKindSecret, bias: 2.0},
|
||||
"cap32": {kind: NodeKindSecret, bias: 2.0},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerZoneGraph(zoneOssuaryAscendantGraph())
|
||||
}
|
||||
61
internal/plugin/zone_graph_ossuary_ascendant_test.go
Normal file
61
internal/plugin/zone_graph_ossuary_ascendant_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOssuaryAscendantGraph_Invariants(t *testing.T) {
|
||||
assertPostgameGraph(t, ZoneOssuaryAscendant, zoneOssuaryAscendantGraph)
|
||||
}
|
||||
|
||||
// TestOssuaryAscendantGraph_ThreeVerses verifies the signature set-piece:
|
||||
// three NodeKindSecret Verses, each reached through a LockPerception spur
|
||||
// edge (the gate rides the fork→threshold edge; the edge into the Verse
|
||||
// itself is unlocked).
|
||||
func TestOssuaryAscendantGraph_ThreeVerses(t *testing.T) {
|
||||
g := zoneOssuaryAscendantGraph()
|
||||
|
||||
verses := []string{"ossuary_ascendant.f1b2", "ossuary_ascendant.f2b2", "ossuary_ascendant.cap32"}
|
||||
for _, v := range verses {
|
||||
n, ok := g.Nodes[v]
|
||||
if !ok {
|
||||
t.Errorf("missing Verse node %s", v)
|
||||
continue
|
||||
}
|
||||
if n.Kind != NodeKindSecret {
|
||||
t.Errorf("Verse %s kind = %q, want secret", v, n.Kind)
|
||||
}
|
||||
}
|
||||
if got := countSecretNodes(g); got != 3 {
|
||||
t.Errorf("secret nodes = %d, want 3 (the Phylactery Verses)", got)
|
||||
}
|
||||
|
||||
// Each Verse's spur is entered through a Perception gate.
|
||||
percThresholds := map[string]bool{
|
||||
"ossuary_ascendant.f1b1": false,
|
||||
"ossuary_ascendant.f2b1": false,
|
||||
"ossuary_ascendant.cap31": false,
|
||||
}
|
||||
for _, outs := range g.Edges {
|
||||
for _, e := range outs {
|
||||
if e.Lock == LockPerception {
|
||||
if _, ok := percThresholds[e.To]; ok {
|
||||
percThresholds[e.To] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for th, gated := range percThresholds {
|
||||
if !gated {
|
||||
t.Errorf("Verse threshold %s is not behind a LockPerception edge", th)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func countSecretNodes(g ZoneGraph) int {
|
||||
n := 0
|
||||
for _, node := range g.Nodes {
|
||||
if node.Kind == NodeKindSecret {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
77
internal/plugin/zone_graph_unplace.go
Normal file
77
internal/plugin/zone_graph_unplace.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package plugin
|
||||
|
||||
// The Unplace branching graph (Tier 6, Phase P4).
|
||||
//
|
||||
// The unattended tear Belaxath left behind — geometry that gave up. Regions:
|
||||
// The Fray → Escher's Debt → The Stitchworks → The Seam, on the shared T6
|
||||
// skeleton. Longest walk = 46, in band.
|
||||
//
|
||||
// The Unplace's set-piece is its locks: every fork gates on a different way
|
||||
// of not-trusting-the-walls (Perception to see the true corner, INT to
|
||||
// out-think the geometry, DEX to move through a room that is also another
|
||||
// room). The secret spurs are high-LootBias impossible-geometry pockets
|
||||
// rather than named secrets — the reward is the shortcut nobody should be
|
||||
// able to take.
|
||||
|
||||
func zoneUnplaceGraph() ZoneGraph {
|
||||
perc := func(dc int, hint string) pgLock {
|
||||
return pgLock{kind: LockPerception, lockData: map[string]any{"dc": dc}, hint: hint}
|
||||
}
|
||||
stat := func(s string, dc int, hint string) pgLock {
|
||||
return pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": s, "dc": dc}, hint: hint}
|
||||
}
|
||||
return buildPostgameZoneGraph(pgZoneSpec{
|
||||
zoneID: ZoneUnplace,
|
||||
regions: [4]string{"unplace_the_fray", "unplace_eschers_debt", "unplace_stitchworks", "unplace_the_seam"},
|
||||
entry: "The Frayed Edge",
|
||||
preamble: [12]string{
|
||||
"The Fray", "Horizon Indoors", "The Borrowed Corner", "Room That Is Also",
|
||||
"Angleworn Passage", "The Miss You Took", "Fraying Snare", "Silhouette Hall",
|
||||
"The Uncounted Arms", "Escher's Approach", "Debt Landing", "The Owed Turn",
|
||||
},
|
||||
fork1: pgFork{
|
||||
label: "The First Impossibility",
|
||||
freeLabel: [3]string{"Straight Enough Line", "Plain Corridor", "Debt Floor"},
|
||||
lockLabel: [3]string{"Corner-Behind-You", "The Pocket That Isn't", "Folded Alcove"},
|
||||
lock: perc(16, "a corner you already passed is somehow ahead of you"),
|
||||
},
|
||||
r2gate: "Escher's Debt",
|
||||
r2build: [6]string{
|
||||
"Stair Up Into Down", "The Unpaid Landing", "Gallery of Wrong Angles",
|
||||
"Nalfeshnee Concourse", "The Recursive Hall", "Stitchworks Threshold",
|
||||
},
|
||||
fork2: pgFork{
|
||||
label: "The Second Impossibility",
|
||||
freeLabel: [3]string{"Threadbare Walk", "Loose-Weave Hall", "Stitchworks Floor"},
|
||||
lockLabel: [3]string{"Seam-Behind-Seam", "The Unstitched Pocket", "Needled Recess"},
|
||||
lock: stat("INT", 17, "the geometry has a rule; find it and the shortcut is legal"),
|
||||
},
|
||||
r3gate: "The Stitchworks",
|
||||
r3build: [6]string{
|
||||
"Rows of Half-Sewn Doors", "The Needle Gallery", "Marilith Loom",
|
||||
"Echo of Belaxath", "Seam Antechamber", "Threshold of the Tear",
|
||||
},
|
||||
capstone: pgCapstone{
|
||||
label: "The Seam Fork",
|
||||
freeLabel: [3]string{"Direct Seam-Walk", "The Open Tear", "Seam Floor"},
|
||||
lockALabel: [3]string{"Sideways Step", "The Room Behind This One", "Slipped Path"},
|
||||
lockA: stat("DEX", 18, "step through the wall while it is also a door"),
|
||||
lockBLabel: [3]string{"Hairline Unreality", "The Undone Pocket", "Impossible Shortcut"},
|
||||
lockB: perc(18, "there is a place that is not here, and it is closer than the door"),
|
||||
},
|
||||
merge: "The Seam",
|
||||
approach: [5]string{
|
||||
"The Far Half Speaking", "Sewn Into the Wound", "Needle-Rain Approach", "The Inside-Out Room", "The Last Stitch",
|
||||
},
|
||||
boss: "The Seamstress",
|
||||
overrides: map[string]pgNodeOverride{
|
||||
"f1b2": {kind: NodeKindSecret, bias: 2.25},
|
||||
"f2b2": {kind: NodeKindSecret, bias: 2.25},
|
||||
"cap32": {kind: NodeKindSecret, bias: 2.5},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerZoneGraph(zoneUnplaceGraph())
|
||||
}
|
||||
25
internal/plugin/zone_graph_unplace_test.go
Normal file
25
internal/plugin/zone_graph_unplace_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUnplaceGraph_Invariants(t *testing.T) {
|
||||
assertPostgameGraph(t, ZoneUnplace, zoneUnplaceGraph)
|
||||
}
|
||||
|
||||
// TestUnplaceGraph_ImpossibleShortcuts verifies the three high-bias secret
|
||||
// pockets and that its capstone offers the "not here" Perception shortcut.
|
||||
func TestUnplaceGraph_ImpossibleShortcuts(t *testing.T) {
|
||||
g := zoneUnplaceGraph()
|
||||
if got := countSecretNodes(g); got != 3 {
|
||||
t.Errorf("secret nodes = %d, want 3", got)
|
||||
}
|
||||
percCapstone := false
|
||||
for _, e := range g.outgoingEdges("unplace.fork3") {
|
||||
if e.Lock == LockPerception {
|
||||
percCapstone = true
|
||||
}
|
||||
}
|
||||
if !percCapstone {
|
||||
t.Error("Unplace capstone should offer a LockPerception shortcut spur")
|
||||
}
|
||||
}
|
||||
3
main.go
3
main.go
@@ -169,6 +169,9 @@ func main() {
|
||||
// Games & Economy
|
||||
euroPlugin := plugin.NewEuroPlugin(client)
|
||||
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.NewHangmanPlugin(client, euroPlugin, dictClient))
|
||||
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user