mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
8 Commits
mischief-m
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d402343e6 | ||
|
|
ab2bcf0c59 | ||
|
|
65a48b4bd7 | ||
|
|
3563519db1 | ||
|
|
4e0b8a298c | ||
|
|
c368257896 | ||
|
|
f7ddbf8858 | ||
|
|
99daac3c2b |
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,20 @@ 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'`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -464,6 +478,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,6 +1780,7 @@ 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,
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -62,12 +63,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 +125,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 +163,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 +197,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 +213,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
|
||||
@@ -235,12 +274,8 @@ 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
|
||||
// post sends one payload to a Pete 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)
|
||||
}
|
||||
|
||||
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 +296,139 @@ 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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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,6 +346,7 @@ 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
|
||||
@@ -237,12 +355,13 @@ type mischiefContract struct {
|
||||
}
|
||||
|
||||
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, ''),
|
||||
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.Signed, &c.EscalationCount, &c.EscalatedBy, &c.BlessingCount, &c.Source, &c.Outcome,
|
||||
&c.CreatedAt, &c.WindowEndsAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -271,6 +390,44 @@ func insertMischiefContract(c *mischiefContract) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 +637,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 +654,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 +678,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 +790,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 +799,153 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e
|
||||
mischiefBuyerNote(signed)))
|
||||
}
|
||||
|
||||
// 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 +974,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 +1066,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 +1089,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 +1120,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,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -413,6 +416,131 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
|
||||
return balance
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotent money moves
|
||||
//
|
||||
// Every caller of Credit/Debit above is a Matrix message, which arrives exactly
|
||||
// once. Money that moves over a retrying wire — the Pete games escrow poll loop
|
||||
// — has no such guarantee: a claim that succeeds but whose ack is lost gets
|
||||
// retried, and the player pays twice. CreditIdem/DebitIdem take an externalID
|
||||
// (the escrow GUID), do the balance mutation and the transaction log in one
|
||||
// tx, and lean on idx_euro_tx_external so a replay is a no-op rather than a
|
||||
// second debit.
|
||||
//
|
||||
// Anything web-initiated goes through these. Nothing else should.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DebitIdem subtracts euros exactly once for a given externalID. Returns ok=false
|
||||
// only when the debit would breach the debt limit; a replay of an already-applied
|
||||
// externalID returns ok=true without moving money again. balanceAfter is the
|
||||
// balance as of the decision, and is meaningless when ok is false.
|
||||
func (p *EuroPlugin) DebitIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) {
|
||||
if amount <= 0 {
|
||||
return false, 0, fmt.Errorf("euro: DebitIdem non-positive amount %v", amount)
|
||||
}
|
||||
if externalID == "" {
|
||||
return false, 0, fmt.Errorf("euro: DebitIdem requires an external id")
|
||||
}
|
||||
debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000)
|
||||
return p.applyIdem(userID, -amount, reason, externalID, -debtLimit)
|
||||
}
|
||||
|
||||
// CreditIdem adds euros exactly once for a given externalID. A replay is a no-op
|
||||
// that still reports ok.
|
||||
func (p *EuroPlugin) CreditIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) {
|
||||
if amount <= 0 {
|
||||
return false, 0, fmt.Errorf("euro: CreditIdem non-positive amount %v", amount)
|
||||
}
|
||||
if externalID == "" {
|
||||
return false, 0, fmt.Errorf("euro: CreditIdem requires an external id")
|
||||
}
|
||||
// A credit has no floor to breach — math.Inf would do, but the balance can
|
||||
// never land below the current one, so any sentinel below it works.
|
||||
return p.applyIdem(userID, amount, reason, externalID, math.Inf(-1))
|
||||
}
|
||||
|
||||
// applyIdem is the shared body: signed delta, applied once, never letting the
|
||||
// balance fall below floor. Balance mutation and transaction log commit together
|
||||
// or not at all.
|
||||
func (p *EuroPlugin) applyIdem(userID id.UserID, delta float64, reason, externalID string, floor float64) (bool, float64, error) {
|
||||
// Outside the tx: db is capped at a single connection, so a nested Exec on
|
||||
// the pool would deadlock against our own open transaction.
|
||||
p.ensureBalance(userID)
|
||||
|
||||
d := db.Get()
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return false, 0, fmt.Errorf("euro: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after a successful Commit
|
||||
|
||||
// Already applied? Report the current balance and move on.
|
||||
var prior int
|
||||
switch err := tx.QueryRow(
|
||||
`SELECT 1 FROM euro_transactions WHERE external_id = ?`, externalID,
|
||||
).Scan(&prior); {
|
||||
case err == nil:
|
||||
bal, err := txBalance(tx, userID)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
slog.Info("euro: idempotent replay ignored", "user", userID, "external_id", externalID, "reason", reason)
|
||||
return true, bal, nil
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Not applied yet — carry on.
|
||||
default:
|
||||
return false, 0, fmt.Errorf("euro: idem lookup: %w", err)
|
||||
}
|
||||
|
||||
res, err := tx.Exec(
|
||||
`UPDATE euro_balances SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ? AND (balance + ?) >= ?`,
|
||||
delta, string(userID), delta, floor,
|
||||
)
|
||||
if err != nil {
|
||||
return false, 0, fmt.Errorf("euro: idem balance update: %w", err)
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return false, 0, nil // would breach the floor
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO euro_transactions (user_id, amount, reason, external_id) VALUES (?, ?, ?, ?)`,
|
||||
string(userID), delta, reason, externalID,
|
||||
); err != nil {
|
||||
// A racing caller won with the same externalID. The unique index is the
|
||||
// backstop the SELECT above can't be: roll back our half-applied delta
|
||||
// and report theirs.
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
return false, 0, fmt.Errorf("euro: rollback after idem race: %w", rbErr)
|
||||
}
|
||||
slog.Info("euro: idempotent race lost, delta rolled back", "user", userID, "external_id", externalID)
|
||||
return true, p.GetBalance(userID), nil
|
||||
}
|
||||
return false, 0, fmt.Errorf("euro: idem tx log: %w", err)
|
||||
}
|
||||
|
||||
bal, err := txBalance(tx, userID)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, 0, fmt.Errorf("euro: commit: %w", err)
|
||||
}
|
||||
return true, bal, nil
|
||||
}
|
||||
|
||||
func txBalance(tx *sql.Tx, userID id.UserID) (float64, error) {
|
||||
var bal float64
|
||||
if err := tx.QueryRow(
|
||||
`SELECT balance FROM euro_balances WHERE user_id = ?`, string(userID),
|
||||
).Scan(&bal); err != nil {
|
||||
return 0, fmt.Errorf("euro: read balance: %w", err)
|
||||
}
|
||||
return bal, nil
|
||||
}
|
||||
|
||||
func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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))
|
||||
|
||||
225
pete_adventure_news_plan.md
Normal file
225
pete_adventure_news_plan.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Pete / Gogobee Adventure News Integration — quick plan
|
||||
|
||||
> High-level only. No assumptions about current code shape; codebase has moved
|
||||
> since Pete was last touched. This is a scoping doc, not an implementation plan.
|
||||
|
||||
## What this is
|
||||
|
||||
A news.parodia.dev section where Pete reports on Adventure module activity:
|
||||
deaths, boss defeats, new players, notable events, and periodic generated
|
||||
recaps. Distinct from the in-bot commands (`!town`, `!graveyard`, `!rivals
|
||||
board`) that already surface this data on request — this makes the same kind
|
||||
of information ambient and discoverable instead of command-gated.
|
||||
|
||||
## Relationship to existing plans
|
||||
|
||||
- **Converges with "Phase 16 Pete" (the deferred Pete bot) — doesn't collide.**
|
||||
The news feed is the near-term, separable slice and ships first. But the
|
||||
Phase 16 bot turns out to be the thing that makes the feed *first-person*: an
|
||||
adventuring Pete is an embedded correspondent who files dispatches from
|
||||
dungeons he's actually in. Feed first, bot later, same character. See "Pete as
|
||||
a character" below.
|
||||
- **Overlaps with Track E (E3 — surfacing the buried social data).** E3 builds
|
||||
`!town`, `!graveyard`, `!rivals board` as pull-based bot commands. Pete's
|
||||
news feed is the push-based counterpart to the same underlying data. Ideally
|
||||
they share one source of truth rather than each growing its own read path.
|
||||
- **Depends on C3 (World boss / "the Siege") shipping.** That's sequenced in
|
||||
N6, well after N4's town work. Pete's biggest story beat — the town under
|
||||
attack — doesn't exist as an event to report on until C3 lands. Fine to plan
|
||||
for now, but the event-feed and stats sections can go live well before this
|
||||
piece is reachable.
|
||||
|
||||
## Content types
|
||||
|
||||
1. **Event feed (low effort, high value)**
|
||||
- Deaths (source, location) — same data `!graveyard` already surfaces.
|
||||
- Boss defeats — first-clears, notable kills.
|
||||
- New player arrivals.
|
||||
- Zone first-clears / notable milestones.
|
||||
- **World boss / "the Siege" (C3).** This is the marquee story beat: a
|
||||
named boss camps outside town for 72h with a shared community HP pool.
|
||||
Pete should report both ends of it — the announcement/start of a Siege,
|
||||
and the resolution (boss falls and payout goes out, or it survives and
|
||||
"loots the town," a visible pot tribute). The town-attack framing already
|
||||
baked into C3's design (it's explicitly a communal threat, not just a
|
||||
tougher solo fight) makes it the best fit for the generated-article
|
||||
treatment, not just a log line.
|
||||
- This is closest to a straightforward log-to-article pass. Good starting
|
||||
point since it needs the least judgment about what's "interesting."
|
||||
|
||||
2. **Generated articles (higher effort, more judgment required)**
|
||||
- Narrative recaps of notable happenings — a boss kill or first-clear
|
||||
written up as a short story beat rather than a log line, in the same
|
||||
spirit as the flavor-voice work already done elsewhere in the bot
|
||||
ecosystem.
|
||||
- LLM-authored via Gogobee's existing inference access, following the same
|
||||
pattern Petal already established: model as a config value behind an
|
||||
abstraction, not hardcoded. Pete's news writing shouldn't need its own
|
||||
bespoke integration if that seam already exists.
|
||||
- Event data (who, what, where, stakes) stays structured and feeds the
|
||||
model as context; the model's job is prose, not fact invention. Worth
|
||||
deciding early whether there's a review/approval step before publish, or
|
||||
whether it goes straight to the site — the risk profile is different
|
||||
between "reads a little dry" and "confidently wrong about who died."
|
||||
- Open question: live per-event, or batched into a daily/weekly digest.
|
||||
Batching gives editorial control over what's worth writing up instead of
|
||||
reporting every routine kill, and is also cheaper on inference calls than
|
||||
firing one generation per event — probably the safer default to start.
|
||||
|
||||
3. **Player stats**
|
||||
- Needs the same leak-check the engagement plan already flags for E3:
|
||||
nothing that ranks or badges donation counts (Misty/Arina), since that
|
||||
could let players reverse-engineer secret NPC buffs. Tax-ledger totals
|
||||
were called out as safe; donation counts were not. Any stat Pete
|
||||
considers for public display should be run through that same filter
|
||||
before it's greenlit, not decided ad hoc per-stat.
|
||||
- Safer starting stats: playtime/level distributions, zone clear counts,
|
||||
arena standings, rival board — anything already deemed room-safe under
|
||||
E3's design.
|
||||
|
||||
## Sequencing suggestion
|
||||
|
||||
1. Event feed first (deaths, boss defeats, new players) — mechanical, low
|
||||
judgment, reuses E3's data model.
|
||||
2. Stats section next, gated through the leak-check before anything ships.
|
||||
3. Generated articles last — highest creative/quality bar, and benefits from
|
||||
having the event feed already running as its raw material.
|
||||
|
||||
## Resolved decisions
|
||||
|
||||
- **Public-facing — yes.** The wall isn't what protects players; the leak-check
|
||||
and name-scoping are, and those apply either way. So publish publicly, but
|
||||
only the public-safe subset, decided on purpose:
|
||||
- **Safe to publish:** boss first-clears, Siege announcements/resolutions,
|
||||
zone clear counts, arena standings, aggregate level/playtime distributions,
|
||||
the rival board.
|
||||
- **Community-only or omit:** anything donation-derived (Misty/Arina secret
|
||||
buffs), anything that names a Matrix account, and raw per-player time series
|
||||
fine-grained enough to diff and reverse-engineer a hidden buff. Public
|
||||
raises the adversary from "a curious member" to "anyone with a scraper," so
|
||||
the leak-check is load-bearing, not advisory.
|
||||
- **Name-scoping:** report on **character names only**, never the Matrix
|
||||
handle behind them. Consider a per-player opt-out of being named in
|
||||
articles. Decide before launch — retroactively scrubbing an indexed/cached
|
||||
public page is the URL-preview problem again.
|
||||
|
||||
## Pete as a character (forward tier — after the feed + section land)
|
||||
|
||||
The most ambitious slice, and the payoff for the whole arc: Pete stops being a
|
||||
wire service that reports on others and becomes a **playable character in the
|
||||
world** — the realm's embedded correspondent. Converges with the deferred
|
||||
Phase 16 Pete bot. Two surfaces, both feeding the news:
|
||||
|
||||
1. **Solo runs.** Pete runs his own expeditions on autopilot (the existing
|
||||
`runAutopilotWalk` path the sim already drives) and files first-person
|
||||
dispatches: *"Your correspondent nearly didn't make it out of the
|
||||
Underforge."* His deaths/level-ups/kills emit the same fact payload every
|
||||
player does, so the news writes itself — and the fact-guard is trivially safe
|
||||
when the subject is Pete himself.
|
||||
|
||||
2. **Hireable companion (class-fluid).** Players short on help can hire Pete
|
||||
into their party. Unlike a player character he is **role-fluid** — he fills
|
||||
the gap: no healer → Cleric, no damage → Mage, need a body up front →
|
||||
Fighter. Default behavior is auto-fill the missing role, with a manual
|
||||
override. When hired, that expedition becomes a dispatch too: *"Filled in as
|
||||
cleric for [party] today — nobody died on my watch."*
|
||||
|
||||
3. **Standing rival (the house challenger).** Players can challenge Pete to a
|
||||
duel any time via the existing C2 staked/no-death system — so there's always
|
||||
an opponent even when the game is quiet (same trailing-case lift as hiring,
|
||||
for the PvP side). He **responds to win and loss himself**, first person and
|
||||
gracious either way — his losses are the better content. First time anyone
|
||||
beats him is an alert. Rival results feed the news via `adventure_rival_records`.
|
||||
|
||||
### Identity — Pete owns Pete (corrected after reading the code)
|
||||
|
||||
`@pete:parodia.dev` is already an **independent bot** (pete repo) with its own
|
||||
account; gogobee ignores it via `IGNORED_BOTS`. So Pete is NOT a gogobee
|
||||
appservice ghost — his Matrix presence is his own bot, and **Pete owns his own
|
||||
voice** (persona, templates, editorial). See `project_pete_bot_architecture`.
|
||||
|
||||
- **Pete's surfaces (all his bot):** the news website, his Matrix posts, and —
|
||||
for the character tier — duel reactions / hire banter / replies. He's already
|
||||
an in-room command bot (`!post`, `!petestats`), so he has the interactive
|
||||
skeleton; the character behaviors extend it.
|
||||
- **gogobee's job:** emit game-event *facts* to Pete, and provide LLM *compute*
|
||||
via a generic endpoint. It does NOT voice Pete. The `📣 Pete:` broadcast lines
|
||||
currently hardcoded in gogobee's flavor file are the old two-Petes situation
|
||||
and should migrate to facts Pete voices.
|
||||
- **LLM:** Pete stays LLM-free in his own codebase (deliberate — it was
|
||||
removed). When he wants prose he calls gogobee's generic tailnet inference
|
||||
endpoint. Default is template-only; LLM is opt-in per marquee beat.
|
||||
|
||||
Why this fits the existing guardrails:
|
||||
|
||||
- **It's the sanctioned difficulty lever.** A short-handed / missing-a-role
|
||||
party is the trailing case; a hireable Pete lifts exactly that party without
|
||||
nerfing leaders or touching monster scaling. Tune him as a competent
|
||||
*below-median* member of whatever class he plays — help, never a carry.
|
||||
- **Balance corpus stays clean.** Golden pins run solo, so baseline sims never
|
||||
hire Pete. Hiring is a live player-help feature entirely outside the baseline;
|
||||
the two paths never cross.
|
||||
- **Gold sink.** Hiring costs gold per expedition — economy pressure of the same
|
||||
kind the tax-ledger / lottery tuning already wants. Scarcity is a knob:
|
||||
Pete "on assignment" (off covering a Siege) is both a reason he's sometimes
|
||||
unavailable and a story in itself.
|
||||
- **Duel economy caution.** As a standing rival he's *beatable* by design — so
|
||||
his duel stakes must be bragging-rights / bounded, NOT real gold, or players
|
||||
farm the house challenger for income. Tuning-pass number; flagged so it isn't
|
||||
discovered as an exploit.
|
||||
- **Guardrails.** Pete keeps clear of the Misty/Arina secret-buff mechanics (a
|
||||
public reporter benefiting from hidden buffs and reporting outcomes is a leak
|
||||
vector). His voice is his own — NOT TwinBee's.
|
||||
|
||||
Open tuning-pass questions (not blockers): hire cost (flat vs level-scaled);
|
||||
his power scaling (fixed level vs scales-to-party); availability (always vs
|
||||
cooldown / on-assignment scarcity).
|
||||
|
||||
## Gaps & risks register
|
||||
|
||||
The design's blind spot was treating the gogobee→Pete channel as internal and
|
||||
trusted — it's neither once player-named characters and LLM prose flow onto a
|
||||
public front page. Seven gaps, three of them must-solve-before-launch.
|
||||
|
||||
1. **[MUST — security] Untrusted input into LLM prompt + public HTML.** A
|
||||
character name is player-controlled. It reaches (a) the LLM prompt →
|
||||
prompt-injection, and (b) rendered `content` → XSS. Fix: names get escaped
|
||||
before the prompt; Adventure `content` goes through Pete's **existing** RSS
|
||||
sanitizer (it already strips inline scripts), never around it.
|
||||
2. **[MUST] `article_url` has nowhere to point.** Schema is `NOT NULL`; a
|
||||
self-hosted event has no external article. Point it at a Pete permalink
|
||||
(`/adventure/<guid>` / the existing per-story reader page).
|
||||
3. **[MUST] Visual identity.** No OG images → the section looks broken next to
|
||||
RSS cards. Ship per-event-type art (Siege banner, graveyard mark, Pete's
|
||||
avatar for his own dispatches).
|
||||
4. **[RESOLVED] Selection/aggregation thresholds.** Data-derived from the prod
|
||||
DB — see the thresholds section in the voice spec. Dormant community → the
|
||||
risk is emptiness, not spam; near-inclusive, with grind telemetry filtered
|
||||
out and achievements rarity-gated.
|
||||
5. **[RESOLVED — security] Opt-out.** Default-featured (informed, announced at
|
||||
launch), one gogobee command backed by a `news_optout` table (mirrors the
|
||||
existing `shade_optout`). Enforced at emit time. Opt-out **anonymizes, not
|
||||
deletes** ("an adventurer…"). Retroactive: `POST /ingest/adventure/scrub`
|
||||
anonymizes existing rows; whole section is `noindex` to bound the
|
||||
cached-forever problem.
|
||||
6. **[RESOLVED — security] Kill-switch.** Global `adventure_news_enabled` flag
|
||||
in gogobee kills emission at the source (generation is gogobee-side, so this
|
||||
is the real switch). Per-story **admin delete on Pete**, reusing the OIDC
|
||||
`adminSubs` gate that already guards `/status`.
|
||||
7. **[RESOLVED] Cold-start backfill.** Guarded one-shot in gogobee replays the
|
||||
back-catalog (realm-firsts, deaths, notable clears, single-holder
|
||||
achievements) through the emit path. Backdated `published_at`, dispatch tone,
|
||||
**no web-push**. Idempotent on `guid`; kept as a marked one-shot (not deleted
|
||||
at close-out) so fresh deploys don't re-ghost-town. At this volume, backfill
|
||||
is most of the launch content, not a nice-to-have.
|
||||
|
||||
## Open questions to settle before scoping further
|
||||
|
||||
- Does Pete write live off events, or on a batch cadence?
|
||||
- Single source of truth for this data shared with E3's bot commands, or a
|
||||
separate read path for the news site?
|
||||
- Voice: does Pete have an established voice yet, or does this news section
|
||||
define it?
|
||||
- Which model tier for article generation — same one Petal's conversational
|
||||
side uses, or a separate config entry, given the different latency/quality
|
||||
tradeoffs of a batch job versus interactive chat?
|
||||
224
pete_adventure_news_voice.md
Normal file
224
pete_adventure_news_voice.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Pete × Parodia — Adventure section voice + architecture spec
|
||||
|
||||
> Companion to `pete_adventure_news_plan.md`. Pete is an independent LLM-free bot
|
||||
> that OWNS his voice; gogobee is the game-event source + generic LLM compute.
|
||||
> Voice: a warm, friendly reporter (owner's call). See
|
||||
> `project_pete_bot_architecture` memory.
|
||||
|
||||
## Architecture (the load-bearing part)
|
||||
|
||||
- **Pete owns voice, authoring, identity, publishing.** Persona, templates,
|
||||
editorial, the `@pete:parodia.dev` account. Pete decides what is said and says
|
||||
it. Pete's codebase has **zero** Ollama/vLLM connectivity, by design.
|
||||
- **gogobee owns game events + raw LLM compute.** Source of truth for what
|
||||
happened; the only codebase that touches the models.
|
||||
- **gogobee → Pete: structured event *facts*** (the payload below), NOT finished
|
||||
prose. gogobee is compute, not ghostwriter.
|
||||
- **Pete → gogobee: a generic, voice-agnostic inference endpoint.** Prompt in,
|
||||
completion out — gogobee never sees "Pete." Pete builds the voiced prompt in
|
||||
his own codebase and calls it. Reusable for Pete's other channels later.
|
||||
- **Network:** both directions ride the Headscale tailnet (Parodia ↔ LAN box).
|
||||
gogobee's inference endpoint is tailnet-bound + bearer-authed; Ollama/vLLM
|
||||
stays `localhost` on the LAN box, never on the tailnet.
|
||||
|
||||
## Voice — Pete the friendly reporter
|
||||
|
||||
Pete is a **reporter, and a warm one** — think a beloved local newscaster who
|
||||
genuinely knows everyone in town and is glad to see you. Journalistic bones
|
||||
(clear headline, a who/what/where/when lede, gets the facts right) delivered
|
||||
with a personable, first-person warmth. He roots for the community, celebrates
|
||||
wins, mourns losses gently, and welcomes newcomers by name. Conversational,
|
||||
never snarky. Friendly, not a caps-lock hype-man.
|
||||
|
||||
- Celebratory win: *"What a turnout — the town held the line tonight."*
|
||||
- Gentle loss: *"Sad news to pass along today."*
|
||||
- Warm welcome: *"Say hello if you see them out there."*
|
||||
- His own sign-off: a short reporter's tag closes bigger pieces — *"Reporting
|
||||
from {region}, this is Pete."*
|
||||
|
||||
Tiering is by **prefix + placement**, not by shouting:
|
||||
- **PRIORITY** (rare, live): Sieges, world-firsts, deaths, a new arrival, the
|
||||
first time Pete loses a duel. Posted immediately.
|
||||
- **BULLETIN** (batched daily digest): everything else worth noting.
|
||||
|
||||
Warmth carries the register, not exclamation marks. A Siege is urgent but Pete
|
||||
is steady and rallying, not panicked: *"Folks, this is the big one — and the
|
||||
whole community's needed. Let's rally."*
|
||||
|
||||
## Hard rules (govern templates AND any LLM prose)
|
||||
|
||||
1. **Character names only.** Never the Matrix handle behind a character.
|
||||
2. **No donation-derived anything** (Misty/Arina secret buffs).
|
||||
3. **Facts are supplied, never invented.** Names/numbers/outcomes come only from
|
||||
the payload `actors[]` allow-list; the fact-guard rejects anything else.
|
||||
4. **Public front-page surface.** Safe-subset rule from the plan applies.
|
||||
|
||||
## Fact payload (gogobee → Pete)
|
||||
|
||||
Flat, pre-sanitized. `actors[]` is the ONLY set of names allowed in output.
|
||||
Character names are player-controlled → sanitize before they enter a prompt or
|
||||
rendered HTML (see plan gap #1).
|
||||
|
||||
```
|
||||
event_type siege_start | siege_win | siege_loss | boss_first | boss_kill |
|
||||
zone_first | zone_clear | death | retreat | arrival | standings |
|
||||
rival_result | pete_duel_loss | pete_duel_win | milestone
|
||||
// GUID prefix == event_type (permalink derives its label from it)
|
||||
tier priority | bulletin
|
||||
actors[] ["Brannigan", ...] // allow-list for output
|
||||
subject "Brannigan"
|
||||
opponent "Kif" // duels / rivalries
|
||||
boss "The Hollow King"
|
||||
zone "Dragon's Lair"
|
||||
region "the Underforge"
|
||||
level 14
|
||||
count 37 // defenders, clears, etc.
|
||||
outcome won | lost | ...
|
||||
stakes "72h" / figure
|
||||
occurred_at unix
|
||||
```
|
||||
|
||||
## Templates (Pete the friendly reporter)
|
||||
|
||||
Format: `TIER · cadence`. `content` (article body) is optional LLM prose via the
|
||||
gogobee endpoint; headline/lede are always template — deterministic and safe.
|
||||
|
||||
### siege_start — PRIORITY · live
|
||||
- **headline:** `Breaking: {boss} is marching on the town.`
|
||||
- **lede:** `Folks, this is the big one — {boss} has camped outside the gates and the whole community's needed to turn it back. You've got {stakes}. Let's rally.`
|
||||
|
||||
### siege_win — PRIORITY · live
|
||||
- **headline:** `The town holds! {boss} turned back.`
|
||||
- **lede:** `What a turnout — {count} defenders stood shoulder to shoulder and sent {boss} packing. Spoils are going out now. Proud of you all.`
|
||||
|
||||
### siege_loss — PRIORITY · live
|
||||
- **headline:** `Heavy news: {boss} broke through.`
|
||||
- **lede:** `We gave it everything, but {boss} got past the gates and took its tribute. We'll be ready next time — heads up, everyone.`
|
||||
|
||||
### boss_first — PRIORITY · live
|
||||
- **headline:** `First ever: {subject} brings down {boss}.`
|
||||
- **lede:** `History in {region} today — {subject} is the first anyone's seen clear {boss}. Nobody had done it before. Hats off.`
|
||||
|
||||
### zone_first — PRIORITY (realm-first) · live
|
||||
- **headline:** `{zone} cleared for the very first time.`
|
||||
- **lede:** `{subject} made it through {zone} in {region}{, at level {level}}. Nicely done.`
|
||||
|
||||
### zone_clear — BULLETIN (repeat) · batch
|
||||
- **headline:** `{subject} clears {zone}.`
|
||||
- **lede:** same as zone_first (shared).
|
||||
- gogobee emits `zone_first` (priority) only for the realm's first clear of a
|
||||
zone and `zone_clear` (bulletin) for every later clear, so a repeat is never
|
||||
filed under "first ever" — headline, permalink label, and emblem all match.
|
||||
|
||||
### boss_kill — BULLETIN · batch
|
||||
- **headline:** `{subject} takes down {boss} again.`
|
||||
- **lede:** `Another clean run in {zone} today. Routine for {subject} by now — but still worth a nod.`
|
||||
|
||||
### death — PRIORITY · live
|
||||
- **headline:** `We lost {subject} in {zone}.`
|
||||
- **lede:** `Sad news to pass along: {subject} fell at level {level} in {zone}. The graveyard's a little fuller tonight. Rest easy.`
|
||||
|
||||
### arrival — BULLETIN · batch
|
||||
- **headline:** `Welcome to the realm, {subject}!`
|
||||
- **lede:** `A new {class_race} just walked through the gates. Say hello if you see them out there.`
|
||||
|
||||
### standings — BULLETIN · batch
|
||||
- **headline:** `The rival board's been shaken up.`
|
||||
- **lede:** `{subject} is on the move — here's where the standings sit today.`
|
||||
|
||||
### rival_result — BULLETIN · batch (Pete uninvolved)
|
||||
- **headline:** `{subject} settles the score with {opponent}.`
|
||||
- **lede:** `Their duel went {subject}'s way today, and the board reflects it. Good match, you two.`
|
||||
|
||||
### pete_duel_loss — PRIORITY (first-ever) / BULLETIN · live · FIRST PERSON
|
||||
- **headline (first-ever):** `You got me, {subject}.`
|
||||
- **headline (repeat):** `{subject} got the better of me again.`
|
||||
- **lede:** `Credit where it's due — {subject} beat me fair and square. Good duel. I'll want a rematch when you're ready.`
|
||||
|
||||
### pete_duel_win — BULLETIN · batch · FIRST PERSON
|
||||
- **headline:** `Held my ground against {subject} today.`
|
||||
- **lede:** `Closer than the record will show, honestly — {subject} pushed me. Rematch whenever you like.`
|
||||
|
||||
### retreat — BULLETIN · batch
|
||||
- **headline:** `{subject} backed out of {zone}.`
|
||||
- **lede:** `{subject} turned around {N days in} — {zone} got the better of them
|
||||
this time, at level {level}, and they made the call to walk out rather than
|
||||
push it. Everybody came home breathing, which is the bit that counts. That
|
||||
dungeon'll still be there next week.`
|
||||
- An expedition that ended with the player ALIVE (a solo clock-out retreat, or a
|
||||
party the turn engine broke off). `count` = the day they reached.
|
||||
- Emitted from gogobee's one run-loss chokepoint, gated on the reason: a **death**
|
||||
files its own priority dispatch and must never ALSO appear as a retreat, and an
|
||||
**idle reap** is never filed at all — a player who closed their laptop did not
|
||||
flee anything, and saying so by name would be a lie about a person.
|
||||
- Warm, not a failure notice. Lead with everyone coming home.
|
||||
|
||||
### milestone — BULLETIN · batch
|
||||
- **headline:** `{subject} hits {milestone}.`
|
||||
- **lede:** `One for the books — {subject} just reached {milestone}. The long road continues.`
|
||||
|
||||
## Optional LLM prose (Pete authors, gogobee computes)
|
||||
|
||||
For the PRIORITY beats only, Pete MAY expand `content` into a short friendly
|
||||
write-up in his reporter voice. Pete builds the prompt (his persona + the fact
|
||||
payload) in Pete's
|
||||
codebase and calls gogobee's generic inference endpoint; gogobee returns raw
|
||||
text; Pete posts it. gogobee never authors — it computes.
|
||||
|
||||
Given you removed LLM from Pete deliberately, template-only is the safe default
|
||||
and the data supports it (see thresholds — tiny volume). LLM prose is opt-in per
|
||||
beat, not the baseline.
|
||||
|
||||
### Fallback ladder (never blocks publish)
|
||||
Headline + lede are always template-derived, so Pete publishes regardless.
|
||||
`content` LLM expansion is best-effort:
|
||||
1. Endpoint disabled in Pete config → template body.
|
||||
2. Endpoint unreachable / timeout → template body.
|
||||
3. Empty or malformed completion → template body.
|
||||
4. Fact-guard fails (a name not in `actors[]`, or a number that contradicts the
|
||||
payload) → template body. Guard runs in Pete, on the completion.
|
||||
|
||||
## Selection thresholds (data-derived, prod DB July 2026)
|
||||
|
||||
Measured against live prod (`millenia`, `data/gogobee.db`). Community is
|
||||
**dormant** — 4 players ever (L20–49). Risk is *emptiness*, not spam;
|
||||
near-inclusive.
|
||||
|
||||
Observed (Apr–Jul 2026): deaths ~3 total; boss kills 23 / 62 days; milestones
|
||||
43 / 60 days; achievements 181 (only flood-prone type); arena runs 82 / rivals
|
||||
6; Sieges 0 ever.
|
||||
|
||||
**Negative filter (matters most):** high-volume tables are grind telemetry, NOT
|
||||
news — `adventure_activity_log` successes (mining/fishing/foraging) and
|
||||
expedition-log `interrupt|walk|harvest|ambient|rest|night`. Never publish. Only
|
||||
`milestone`/`recap` entry types, deaths, and boss/zone firsts surface.
|
||||
|
||||
**Rules:**
|
||||
- **PRIORITY (live, individual):** realm-first zone clear, realm-first boss kill,
|
||||
any Siege start/resolve, new-player arrival, any death, first-ever Pete duel loss.
|
||||
- **BULLETIN (daily digest):** repeat boss kills, repeat zone clears,
|
||||
standings/rival shifts, Pete duel wins.
|
||||
- **Rarity-gated:** an achievement publishes only if held by a *single* player.
|
||||
- **Never published:** harvest/mining/fishing outcomes, expedition
|
||||
interrupt/walk/ambient/rest noise.
|
||||
- **Aggregation:** collapse to a count line only if the same `(event_type,
|
||||
target)` recurs **≥3×** in a digest window (historically never triggers).
|
||||
- **Cadence:** bulletin = daily digest; priority fires live.
|
||||
|
||||
## Delivery seam
|
||||
|
||||
### gogobee → Pete: event facts
|
||||
- Transport: gogobee POSTs the fact payload to Pete over the tailnet
|
||||
(or Pete's HTTPS ingest), bearer-authed.
|
||||
- Idempotent on a stable event key (`siege_win:<ts>` etc.) so retries are no-ops.
|
||||
- gogobee owns delivery reliability: queue + retry so a Pete restart loses
|
||||
nothing.
|
||||
- Adventure output is Pete-on-Matrix + the website. gogobee must NOT also
|
||||
announce these itself — the `📣 Pete:` broadcast lines currently hardcoded in
|
||||
gogobee's flavor file should migrate to facts Pete voices (see plan).
|
||||
|
||||
### Pete → gogobee: generic inference
|
||||
- Pete POSTs `{prompt, params}` to gogobee's tailnet inference endpoint,
|
||||
bearer-authed. Completion returned as raw text. gogobee proxies to
|
||||
`localhost` Ollama/vLLM.
|
||||
- Voice-agnostic: gogobee has no Adventure/Pete-specific logic on this path.
|
||||
Reference in New Issue
Block a user