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
|
// leader's own level — his party fled 5 runs out of 640 where the human
|
||||||
// party fled 56.
|
// party fled 56.
|
||||||
`ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`,
|
`ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`,
|
||||||
|
// Mischief M2 — whoever paid to bump a contract up a tier. Named alongside
|
||||||
|
// the buyer when the target survives (the unseal), so piling on carries the
|
||||||
|
// same exposure the original purchase does.
|
||||||
|
`ALTER TABLE mischief_contracts ADD COLUMN escalated_by TEXT`,
|
||||||
|
// Pete games — a caller-supplied idempotency key for money moves that
|
||||||
|
// arrive over a retrying wire rather than a Matrix message. A Matrix
|
||||||
|
// message arrives once; a poll loop whose ack is lost on the wire will
|
||||||
|
// retry, and without this the player pays twice. See euro.DebitIdem.
|
||||||
|
`ALTER TABLE euro_transactions ADD COLUMN external_id TEXT`,
|
||||||
|
// Pete games — the outbound queue carries more than adventure facts now.
|
||||||
|
// An escrow verdict goes to a different Pete endpoint, but it wants the
|
||||||
|
// same durability, backoff and parking, so it rides the same queue and the
|
||||||
|
// row says where it's going. Existing rows are all facts, hence the default.
|
||||||
|
`ALTER TABLE pete_emit_queue ADD COLUMN path TEXT NOT NULL DEFAULT '/api/ingest/adventure'`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
@@ -464,6 +478,18 @@ func runMigrations(d *sql.DB) error {
|
|||||||
return fmt.Errorf("migration %q: %w", stmt, err)
|
return fmt.Errorf("migration %q: %w", stmt, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Indexes over columns the column migrations above just added. These can't
|
||||||
|
// live in the schema block, which runs before those columns exist.
|
||||||
|
indexMigrations := []string{
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_euro_tx_external
|
||||||
|
ON euro_transactions(external_id) WHERE external_id IS NOT NULL`,
|
||||||
|
}
|
||||||
|
for _, stmt := range indexMigrations {
|
||||||
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
return fmt.Errorf("index migration %q: %w", stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1754,6 +1780,7 @@ CREATE TABLE IF NOT EXISTS mischief_contracts (
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
signed INTEGER NOT NULL DEFAULT 0,
|
signed INTEGER NOT NULL DEFAULT 0,
|
||||||
escalation_count INTEGER NOT NULL DEFAULT 0,
|
escalation_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
escalated_by TEXT,
|
||||||
blessing_count INTEGER NOT NULL DEFAULT 0,
|
blessing_count INTEGER NOT NULL DEFAULT 0,
|
||||||
source TEXT NOT NULL DEFAULT 'matrix',
|
source TEXT NOT NULL DEFAULT 'matrix',
|
||||||
outcome TEXT,
|
outcome TEXT,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -62,12 +63,18 @@ type Config struct {
|
|||||||
// so emit hooks scattered across plugins (and free functions like
|
// so emit hooks scattered across plugins (and free functions like
|
||||||
// markAdventureDead) can call Emit without threading a handle through.
|
// markAdventureDead) can call Emit without threading a handle through.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
draining sync.Mutex // one drain at a time; see drain
|
||||||
}
|
}
|
||||||
|
|
||||||
var std *Client
|
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.
|
// Tuning for the background sender.
|
||||||
const (
|
const (
|
||||||
senderTick = 15 * time.Second
|
senderTick = 15 * time.Second
|
||||||
@@ -118,11 +125,20 @@ func Emit(f Fact) {
|
|||||||
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
|
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
|
||||||
return
|
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",
|
db.Exec("pete emit enqueue",
|
||||||
`INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at)
|
`INSERT OR IGNORE INTO pete_emit_queue (guid, path, payload, created_at, attempts, next_attempt_at)
|
||||||
VALUES (?, ?, unixepoch(), 0, 0)`,
|
VALUES (?, ?, ?, unixepoch(), 0, 0)`,
|
||||||
f.GUID, string(payload))
|
guid, path, string(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartSender launches the background drain loop. It runs until ctx is
|
// 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.
|
// 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) {
|
func (c *Client) drain(ctx context.Context) {
|
||||||
|
c.draining.Lock()
|
||||||
|
defer c.draining.Unlock()
|
||||||
|
|
||||||
rows, err := db.Get().Query(
|
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()
|
WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch()
|
||||||
ORDER BY created_at LIMIT ?`,
|
ORDER BY created_at LIMIT ?`,
|
||||||
maxAttempts, senderBatch)
|
maxAttempts, senderBatch)
|
||||||
@@ -158,11 +197,11 @@ func (c *Client) drain(ctx context.Context) {
|
|||||||
slog.Error("peteclient: drain query", "err", err)
|
slog.Error("peteclient: drain query", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
type item struct{ guid, payload string }
|
type item struct{ guid, path, payload string }
|
||||||
var batch []item
|
var batch []item
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it item
|
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)
|
slog.Error("peteclient: drain scan", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -174,7 +213,7 @@ func (c *Client) drain(ctx context.Context) {
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
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 {
|
if ctx.Err() != nil {
|
||||||
// Shutdown canceled the in-flight send — Pete didn't reject
|
// Shutdown canceled the in-flight send — Pete didn't reject
|
||||||
// anything. Don't burn a durable retry attempt; the row is picked
|
// 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)
|
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.
|
// 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 {
|
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
|
||||||
url := c.cfg.IngestURL + path
|
url := c.cfg.IngestURL + path
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
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
|
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
|
// 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.
|
// count so the delay grows geometrically without needing it passed in.
|
||||||
func backoffSec(guid string) int {
|
func backoffSec(guid string) int {
|
||||||
|
|||||||
@@ -74,6 +74,27 @@ const (
|
|||||||
|
|
||||||
// Fizzle: the expedition ended before the monster could find them.
|
// Fizzle: the expedition ended before the monster could find them.
|
||||||
mischiefFizzleRefund = 0.90
|
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
|
// Contract statuses. `delivering` is the claimed-but-unresolved state a CAS
|
||||||
@@ -123,6 +144,102 @@ func mischiefTierByKey(key string) (mischiefTier, bool) {
|
|||||||
return mischiefTier{}, false
|
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
|
// mischiefSignedFee is what a buyer pays to put their name on the contract up
|
||||||
// front. The extra is a sink — mischiefPurse never sees it.
|
// front. The extra is a sink — mischiefPurse never sees it.
|
||||||
func mischiefSignedFee(fee int) int {
|
func mischiefSignedFee(fee int) int {
|
||||||
@@ -229,6 +346,7 @@ type mischiefContract struct {
|
|||||||
Status string
|
Status string
|
||||||
Signed bool
|
Signed bool
|
||||||
EscalationCount int
|
EscalationCount int
|
||||||
|
EscalatedBy id.UserID // "" unless somebody paid to bump the tier
|
||||||
BlessingCount int
|
BlessingCount int
|
||||||
Source string
|
Source string
|
||||||
Outcome string
|
Outcome string
|
||||||
@@ -237,12 +355,13 @@ type mischiefContract struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed,
|
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) {
|
func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error) {
|
||||||
c := &mischiefContract{}
|
c := &mischiefContract{}
|
||||||
err := row.Scan(&c.ID, &c.BuyerID, &c.TargetID, &c.Tier, &c.Fee, &c.Paid, &c.Status,
|
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)
|
&c.CreatedAt, &c.WindowEndsAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -271,6 +390,44 @@ func insertMischiefContract(c *mischiefContract) error {
|
|||||||
return err
|
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
|
// claimMischiefForDelivery moves open → delivering and reports whether THIS
|
||||||
// caller won the race. Exactly one delivery per contract, whatever the ticker
|
// caller won the race. Exactly one delivery per contract, whatever the ticker
|
||||||
// does across a restart.
|
// does across a restart.
|
||||||
@@ -480,9 +637,14 @@ func (p *AdventurePlugin) handleMischiefCmd(ctx MessageContext, args string) err
|
|||||||
return p.mischiefStatusCmd(ctx)
|
return p.mischiefStatusCmd(ctx)
|
||||||
case "send":
|
case "send":
|
||||||
return p.mischiefSendCmd(ctx, fields[1:])
|
return p.mischiefSendCmd(ctx, fields[1:])
|
||||||
|
case "bless":
|
||||||
|
return p.mischiefBlessCmd(ctx, fields[1:])
|
||||||
|
case "escalate":
|
||||||
|
return p.mischiefEscalateCmd(ctx, fields[1:])
|
||||||
default:
|
default:
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
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("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")
|
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 {
|
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)),
|
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("\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._",
|
b.WriteString(fmt.Sprintf("_They have to be out on an expedition, level %d+. It lands about %s later._",
|
||||||
mischiefMinLevel, formatDuration(mischiefWindow)))
|
mischiefMinLevel, formatDuration(mischiefWindow)))
|
||||||
return b.String()
|
return b.String()
|
||||||
@@ -509,10 +678,14 @@ func (p *AdventurePlugin) mischiefStatusCmd(ctx MessageContext) error {
|
|||||||
if c.Signed {
|
if c.Signed {
|
||||||
who = "**" + p.DisplayName(c.BuyerID) + "**"
|
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(
|
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)),
|
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(
|
rows, err := db.Get().Query(
|
||||||
`SELECT `+mischiefCols+` FROM mischief_contracts
|
`SELECT `+mischiefCols+` FROM mischief_contracts
|
||||||
@@ -617,6 +790,7 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.announceMischiefContract(c, tier)
|
p.announceMischiefContract(c, tier)
|
||||||
|
p.dmMischiefVictim(c, tier)
|
||||||
emitMischiefContractNews(c, tier)
|
emitMischiefContractNews(c, tier)
|
||||||
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||||
@@ -625,6 +799,153 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e
|
|||||||
mischiefBuyerNote(signed)))
|
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 {
|
func mischiefBuyerNote(signed bool) string {
|
||||||
if signed {
|
if signed {
|
||||||
return "_Your name is on it. Everyone already knows._"
|
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))))
|
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) {
|
func (p *AdventurePlugin) announceMischiefSurvived(c *mischiefContract, monsterName string, purse int) {
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
if gr == "" {
|
if gr == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The unseal. Anonymity is the buyer's to lose, and losing it is what makes
|
// 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(
|
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"+
|
"🛡️ **%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.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) {
|
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
|
// Deploy Pete BEFORE gogobee whenever these types change: Pete 400s an unknown
|
||||||
// event_type, gogobee retries, and the bulletin parks forever.
|
// 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) {
|
func emitMischiefContractNews(c *mischiefContract, tier mischiefTier) {
|
||||||
target := charName(c.TargetID)
|
target := charName(c.TargetID)
|
||||||
@@ -702,7 +1089,7 @@ func emitMischiefContractNews(c *mischiefContract, tier mischiefTier) {
|
|||||||
emitFact(peteclient.Fact{
|
emitFact(peteclient.Fact{
|
||||||
GUID: fmt.Sprintf("mischief_contract:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
GUID: fmt.Sprintf("mischief_contract:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
||||||
EventType: "mischief_contract",
|
EventType: "mischief_contract",
|
||||||
Tier: "priority",
|
Tier: "bulletin",
|
||||||
Subject: target,
|
Subject: target,
|
||||||
Opponent: buyer,
|
Opponent: buyer,
|
||||||
Boss: tier.Display,
|
Boss: tier.Display,
|
||||||
@@ -733,7 +1120,7 @@ func emitMischiefResolvedNews(c *mischiefContract, monsterName, outcome string,
|
|||||||
emitFact(peteclient.Fact{
|
emitFact(peteclient.Fact{
|
||||||
GUID: fmt.Sprintf("%s:%s:%d", eventType, eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
GUID: fmt.Sprintf("%s:%s:%d", eventType, eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
Tier: "priority",
|
Tier: "bulletin",
|
||||||
Subject: target,
|
Subject: target,
|
||||||
Opponent: buyer,
|
Opponent: buyer,
|
||||||
Boss: monsterName,
|
Boss: monsterName,
|
||||||
|
|||||||
@@ -121,6 +121,15 @@ func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) {
|
|||||||
if !claimMischiefForDelivery(contract.ID) {
|
if !claimMischiefForDelivery(contract.ID) {
|
||||||
return // another tick (or another process) already has it
|
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 {
|
if err := p.deliverMischief(contract, exp); err != nil {
|
||||||
slog.Error("mischief: delivery failed",
|
slog.Error("mischief: delivery failed",
|
||||||
"contract", contract.ID, "target", target, "err", err)
|
"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
|
// row would otherwise be immortal: the target can never be targeted again, and
|
||||||
// the buyer's money never comes back.
|
// the buyer's money never comes back.
|
||||||
//
|
//
|
||||||
// The buyer is made whole in full rather than rake-charged. A stranded delivery
|
// Everyone who paid is made whole in full rather than rake-charged. A stranded
|
||||||
// is our crash, not a bet they lost.
|
// 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) {
|
func (p *AdventurePlugin) sweepStaleMischief(now time.Time) {
|
||||||
for _, c := range loadStaleMischiefDeliveries(now.Add(-mischiefDeliveryGrace)) {
|
for _, c := range loadStaleMischiefDeliveries(now.Add(-mischiefDeliveryGrace)) {
|
||||||
contract := c
|
contract := c
|
||||||
if !abandonStaleMischief(contract.ID) {
|
if !abandonStaleMischief(contract.ID) {
|
||||||
continue
|
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(
|
p.SendDM(contract.BuyerID, fmt.Sprintf(
|
||||||
"😾 Your **%s** never reached **%s** — something went wrong on our end. Fully refunded: %s.",
|
"😾 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",
|
slog.Warn("mischief: swept stranded delivery",
|
||||||
"contract", contract.ID, "target", contract.TargetID)
|
"contract", contract.ID, "target", contract.TargetID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fizzleMischief refunds the buyer (minus the rake) when the monster arrives to
|
// clearStrandedMischiefWard takes back a ward whose delivery died before it could
|
||||||
// an empty dungeon. The CAS is what makes the refund safe: a contract that was
|
// clear its own. The amount is re-derived exactly as applyMischiefBlessings
|
||||||
// simultaneously claimed for delivery cannot also be refunded here.
|
// 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) {
|
func (p *AdventurePlugin) fizzleMischief(c *mischiefContract) {
|
||||||
if !fizzleMischiefContract(c.ID) {
|
if !fizzleMischiefContract(c.ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refund := int(float64(c.Paid) * mischiefFizzleRefund)
|
buyerPaid, escPaid := mischiefOutlay(c)
|
||||||
rake := c.Paid - refund
|
|
||||||
if refund > 0 {
|
refund, rake := p.rakedMischiefRefund(c.BuyerID, buyerPaid, "mischief_fizzle_refund")
|
||||||
p.euro.Credit(c.BuyerID, float64(refund), "mischief_fizzle_refund")
|
|
||||||
}
|
|
||||||
if rake > 0 {
|
|
||||||
communityPotAdd(rake)
|
|
||||||
trackTaxPaid(c.BuyerID, rake)
|
|
||||||
}
|
|
||||||
p.SendDM(c.BuyerID, fmt.Sprintf(
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
"😾 Your **%s** got to the dungeon and found nobody home — **%s** was already out of there.\n"+
|
"😾 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.",
|
"You're refunded %s. The other %s stays with the town, for its trouble.",
|
||||||
c.Tier, p.DisplayName(c.TargetID), fmtEuro(refund), fmtEuro(rake)))
|
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)
|
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
|
// deliverMischief runs the purchased fight and closes it out. By the time this
|
||||||
// is called the contract is claimed, so it resolves exactly once.
|
// is called the contract is claimed, so it resolves exactly once.
|
||||||
func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition) error {
|
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 {
|
if survived {
|
||||||
p.resolveMischiefSurvived(c, tier, exp, bracket, monsterName, narration)
|
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
|
// refundClaimedMischief unwinds a contract that was already claimed for delivery
|
||||||
// but couldn't be staged. The buyer gets everything back — this is our fault,
|
// but couldn't be staged. Everyone who paid gets everything back — this is our
|
||||||
// not a fizzle they gambled on.
|
// fault, not a fizzle they gambled on. The escalator is a payer too; see
|
||||||
|
// mischiefOutlay.
|
||||||
func (p *AdventurePlugin) refundClaimedMischief(c *mischiefContract, reason string) {
|
func (p *AdventurePlugin) refundClaimedMischief(c *mischiefContract, reason string) {
|
||||||
resolveMischiefContract(c.ID, mischiefStatusFizzled, mischiefStatusFizzled)
|
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(
|
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)
|
slog.Warn("mischief: contract unstageable", "contract", c.ID, "reason", reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,10 +325,17 @@ func (p *AdventurePlugin) runMischiefInterrupt(
|
|||||||
bracket ZoneDefinition,
|
bracket ZoneDefinition,
|
||||||
chain []DnDMonsterTemplate,
|
chain []DnDMonsterTemplate,
|
||||||
ambush bool,
|
ambush bool,
|
||||||
|
blessings int,
|
||||||
) (narration, monsterName string, survived bool) {
|
) (narration, monsterName string, survived bool) {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
last := chain[len(chain)-1].Name
|
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 {
|
for i, monster := range chain {
|
||||||
// The ambush is the attacker's privilege — it was lying in wait, and the
|
// 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
|
// 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
|
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
|
// 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
|
// 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
|
// 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)
|
purse := mischiefPurse(c.Fee, tier.PayoutPct)
|
||||||
p.euro.Credit(c.TargetID, float64(purse), "mischief_survived")
|
p.euro.Credit(c.TargetID, float64(purse), "mischief_survived")
|
||||||
|
|
||||||
// Everything the buyer spent that isn't the purse — the rake, plus the whole
|
// Everything spent on the contract that isn't the purse — the rake, plus the
|
||||||
// sign surcharge — is a sink. That is what makes the payout cap bite.
|
// 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 {
|
if rake := c.Paid - purse; rake > 0 {
|
||||||
communityPotAdd(rake)
|
communityPotAdd(rake)
|
||||||
trackTaxPaid(c.BuyerID, rake)
|
trackMischiefSink(c, rake)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extras by tier. Treasure rolls against the zone they're actually in — the
|
// 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(
|
dm.WriteString(fmt.Sprintf(
|
||||||
"\n🛡️ You're still standing, and the coin was never really theirs. **%s** is yours.\n",
|
"\n🛡️ You're still standing, and the coin was never really theirs. **%s** is yours.\n",
|
||||||
fmtEuro(purse)))
|
fmtEuro(purse)))
|
||||||
dm.WriteString(fmt.Sprintf("It was **%s** who paid to have you killed. Do with that what you like.",
|
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.DisplayName(c.BuyerID), p.mischiefEscalatorNote(c)))
|
||||||
for _, e := range extras {
|
for _, e := range extras {
|
||||||
dm.WriteString("\n_" + e + "_")
|
dm.WriteString("\n_" + e + "_")
|
||||||
}
|
}
|
||||||
@@ -380,6 +522,12 @@ func (p *AdventurePlugin) resolveMischiefSurvived(
|
|||||||
p.SendDM(c.BuyerID, fmt.Sprintf(
|
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.",
|
"🛡️ **%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)))
|
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)
|
p.announceMischiefSurvived(c, monsterName, purse)
|
||||||
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeSurvived, purse)
|
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeSurvived, purse)
|
||||||
@@ -414,7 +562,7 @@ func (p *AdventurePlugin) resolveMischiefDowned(
|
|||||||
}
|
}
|
||||||
|
|
||||||
communityPotAdd(c.Paid)
|
communityPotAdd(c.Paid)
|
||||||
trackTaxPaid(c.BuyerID, c.Paid)
|
trackMischiefSink(c, c.Paid)
|
||||||
|
|
||||||
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
|
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"+
|
"💀 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._",
|
"_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)))
|
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)
|
p.announceMischiefDowned(c, monsterName)
|
||||||
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeDowned, 0)
|
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeDowned, 0)
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) {
|
|||||||
bracket := mischiefBracketZone(5)
|
bracket := mischiefBracketZone(5)
|
||||||
chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2)))
|
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 {
|
if !survived {
|
||||||
t.Fatalf("a level-5 fighter at full HP died to a grunt (%s) — the tier is mispriced", monster)
|
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)
|
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)
|
got, _ := mischiefContractByID(c.ID)
|
||||||
if got.Status != mischiefStatusFizzled {
|
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")
|
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
|
// 3. Single-holder achievements — rarity-gated to one holder, matching the live
|
||||||
// milestone rule so routine unlocks don't flood the backlog.
|
// 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
|
// Backfilled facts are backdated to when they happened and marked NoPush (no
|
||||||
// web-push spam on launch). GUIDs are deterministic (keyed on the historical
|
// 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
|
// 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{
|
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()),
|
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",
|
EventType: "zone_first",
|
||||||
Tier: "priority",
|
Tier: "bulletin",
|
||||||
Subject: name,
|
Subject: name,
|
||||||
Zone: zone.Display,
|
Zone: zone.Display,
|
||||||
Boss: zone.Boss.Name,
|
Boss: zone.Boss.Name,
|
||||||
@@ -151,7 +161,7 @@ func backfillDeaths() int {
|
|||||||
emitFact(peteclient.Fact{
|
emitFact(peteclient.Fact{
|
||||||
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date),
|
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date),
|
||||||
EventType: "death",
|
EventType: "death",
|
||||||
Tier: "priority",
|
Tier: "bulletin",
|
||||||
Subject: name,
|
Subject: name,
|
||||||
Zone: d.location,
|
Zone: d.location,
|
||||||
Level: lvl,
|
Level: lvl,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -413,6 +416,131 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
|
|||||||
return balance
|
return balance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Idempotent money moves
|
||||||
|
//
|
||||||
|
// Every caller of Credit/Debit above is a Matrix message, which arrives exactly
|
||||||
|
// once. Money that moves over a retrying wire — the Pete games escrow poll loop
|
||||||
|
// — has no such guarantee: a claim that succeeds but whose ack is lost gets
|
||||||
|
// retried, and the player pays twice. CreditIdem/DebitIdem take an externalID
|
||||||
|
// (the escrow GUID), do the balance mutation and the transaction log in one
|
||||||
|
// tx, and lean on idx_euro_tx_external so a replay is a no-op rather than a
|
||||||
|
// second debit.
|
||||||
|
//
|
||||||
|
// Anything web-initiated goes through these. Nothing else should.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DebitIdem subtracts euros exactly once for a given externalID. Returns ok=false
|
||||||
|
// only when the debit would breach the debt limit; a replay of an already-applied
|
||||||
|
// externalID returns ok=true without moving money again. balanceAfter is the
|
||||||
|
// balance as of the decision, and is meaningless when ok is false.
|
||||||
|
func (p *EuroPlugin) DebitIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) {
|
||||||
|
if amount <= 0 {
|
||||||
|
return false, 0, fmt.Errorf("euro: DebitIdem non-positive amount %v", amount)
|
||||||
|
}
|
||||||
|
if externalID == "" {
|
||||||
|
return false, 0, fmt.Errorf("euro: DebitIdem requires an external id")
|
||||||
|
}
|
||||||
|
debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000)
|
||||||
|
return p.applyIdem(userID, -amount, reason, externalID, -debtLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreditIdem adds euros exactly once for a given externalID. A replay is a no-op
|
||||||
|
// that still reports ok.
|
||||||
|
func (p *EuroPlugin) CreditIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) {
|
||||||
|
if amount <= 0 {
|
||||||
|
return false, 0, fmt.Errorf("euro: CreditIdem non-positive amount %v", amount)
|
||||||
|
}
|
||||||
|
if externalID == "" {
|
||||||
|
return false, 0, fmt.Errorf("euro: CreditIdem requires an external id")
|
||||||
|
}
|
||||||
|
// A credit has no floor to breach — math.Inf would do, but the balance can
|
||||||
|
// never land below the current one, so any sentinel below it works.
|
||||||
|
return p.applyIdem(userID, amount, reason, externalID, math.Inf(-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyIdem is the shared body: signed delta, applied once, never letting the
|
||||||
|
// balance fall below floor. Balance mutation and transaction log commit together
|
||||||
|
// or not at all.
|
||||||
|
func (p *EuroPlugin) applyIdem(userID id.UserID, delta float64, reason, externalID string, floor float64) (bool, float64, error) {
|
||||||
|
// Outside the tx: db is capped at a single connection, so a nested Exec on
|
||||||
|
// the pool would deadlock against our own open transaction.
|
||||||
|
p.ensureBalance(userID)
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, fmt.Errorf("euro: begin: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after a successful Commit
|
||||||
|
|
||||||
|
// Already applied? Report the current balance and move on.
|
||||||
|
var prior int
|
||||||
|
switch err := tx.QueryRow(
|
||||||
|
`SELECT 1 FROM euro_transactions WHERE external_id = ?`, externalID,
|
||||||
|
).Scan(&prior); {
|
||||||
|
case err == nil:
|
||||||
|
bal, err := txBalance(tx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
slog.Info("euro: idempotent replay ignored", "user", userID, "external_id", externalID, "reason", reason)
|
||||||
|
return true, bal, nil
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
// Not applied yet — carry on.
|
||||||
|
default:
|
||||||
|
return false, 0, fmt.Errorf("euro: idem lookup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE euro_balances SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND (balance + ?) >= ?`,
|
||||||
|
delta, string(userID), delta, floor,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, fmt.Errorf("euro: idem balance update: %w", err)
|
||||||
|
}
|
||||||
|
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||||
|
return false, 0, nil // would breach the floor
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO euro_transactions (user_id, amount, reason, external_id) VALUES (?, ?, ?, ?)`,
|
||||||
|
string(userID), delta, reason, externalID,
|
||||||
|
); err != nil {
|
||||||
|
// A racing caller won with the same externalID. The unique index is the
|
||||||
|
// backstop the SELECT above can't be: roll back our half-applied delta
|
||||||
|
// and report theirs.
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
|
if rbErr := tx.Rollback(); rbErr != nil {
|
||||||
|
return false, 0, fmt.Errorf("euro: rollback after idem race: %w", rbErr)
|
||||||
|
}
|
||||||
|
slog.Info("euro: idempotent race lost, delta rolled back", "user", userID, "external_id", externalID)
|
||||||
|
return true, p.GetBalance(userID), nil
|
||||||
|
}
|
||||||
|
return false, 0, fmt.Errorf("euro: idem tx log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bal, err := txBalance(tx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return false, 0, fmt.Errorf("euro: commit: %w", err)
|
||||||
|
}
|
||||||
|
return true, bal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func txBalance(tx *sql.Tx, userID id.UserID) (float64, error) {
|
||||||
|
var bal float64
|
||||||
|
if err := tx.QueryRow(
|
||||||
|
`SELECT balance FROM euro_balances WHERE user_id = ?`, string(userID),
|
||||||
|
).Scan(&bal); err != nil {
|
||||||
|
return 0, fmt.Errorf("euro: read balance: %w", err)
|
||||||
|
}
|
||||||
|
return bal, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
|
func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
|
||||||
db.Exec("euro: log transaction",
|
db.Exec("euro: log transaction",
|
||||||
"INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)",
|
"INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)",
|
||||||
|
|||||||
174
internal/plugin/euro_idem_test.go
Normal file
174
internal/plugin/euro_idem_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEuroTestDB(t *testing.T) *EuroPlugin {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
return NewEuroPlugin(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedBalance(t *testing.T, p *EuroPlugin, user id.UserID, amount float64) {
|
||||||
|
t.Helper()
|
||||||
|
p.ensureBalance(user)
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE euro_balances SET balance = ? WHERE user_id = ?`, amount, string(user),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebitIdem_ReplaySameGUIDDebitsOnce(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@replay:test.invalid")
|
||||||
|
seedBalance(t, p, user, 500)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ok, bal, err := p.DebitIdem(user, 100, "games_buyin", "guid-abc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("attempt %d: debit rejected", i)
|
||||||
|
}
|
||||||
|
if bal != 400 {
|
||||||
|
t.Fatalf("attempt %d: balance after = %v, want 400", i, bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.GetBalance(user); got != 400 {
|
||||||
|
t.Fatalf("final balance = %v, want 400 (replay debited more than once)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM euro_transactions WHERE external_id = 'guid-abc'`,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("logged %d transactions for one guid, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreditIdem_ReplaySameGUIDCreditsOnce(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@cashout:test.invalid")
|
||||||
|
seedBalance(t, p, user, 0)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ok, bal, err := p.CreditIdem(user, 250, "games_cashout", "guid-xyz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if !ok || bal != 250 {
|
||||||
|
t.Fatalf("attempt %d: ok=%v balance=%v, want true/250", i, ok, bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.GetBalance(user); got != 250 {
|
||||||
|
t.Fatalf("final balance = %v, want 250", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebitIdem_DistinctGUIDsBothApply(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@distinct:test.invalid")
|
||||||
|
seedBalance(t, p, user, 500)
|
||||||
|
|
||||||
|
for _, guid := range []string{"guid-1", "guid-2"} {
|
||||||
|
if ok, _, err := p.DebitIdem(user, 100, "games_buyin", guid); err != nil || !ok {
|
||||||
|
t.Fatalf("%s: ok=%v err=%v", guid, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := p.GetBalance(user); got != 300 {
|
||||||
|
t.Fatalf("balance = %v, want 300", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebitIdem_RespectsDebtLimitAndLogsNothing(t *testing.T) {
|
||||||
|
t.Setenv("BLACKJACK_DEBT_LIMIT", "1000")
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@broke:test.invalid")
|
||||||
|
seedBalance(t, p, user, 0)
|
||||||
|
|
||||||
|
ok, _, err := p.DebitIdem(user, 1500, "games_buyin", "guid-broke")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("debit past the debt limit was accepted")
|
||||||
|
}
|
||||||
|
if got := p.GetBalance(user); got != 0 {
|
||||||
|
t.Fatalf("balance = %v, want 0 — a rejected debit moved money", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rejection must leave no trace, so the same guid can be retried later
|
||||||
|
// (e.g. once the player has earned their way back above the limit).
|
||||||
|
var n int
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM euro_transactions WHERE external_id = 'guid-broke'`,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("rejected debit logged %d transactions, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SELECT-then-UPDATE in applyIdem is not by itself a guard against two
|
||||||
|
// concurrent claims of the same guid; the unique index is. Hammer it.
|
||||||
|
func TestDebitIdem_ConcurrentSameGUIDDebitsOnce(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@race:test.invalid")
|
||||||
|
seedBalance(t, p, user, 1000)
|
||||||
|
|
||||||
|
const racers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make([]error, racers)
|
||||||
|
for i := 0; i < racers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _, errs[i] = p.DebitIdem(user, 100, "games_buyin", "guid-race")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("racer %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := p.GetBalance(user); got != 900 {
|
||||||
|
t.Fatalf("balance = %v, want 900 — %d concurrent claims of one guid double-debited", got, racers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdem_RejectsEmptyExternalID(t *testing.T) {
|
||||||
|
p := newEuroTestDB(t)
|
||||||
|
user := id.UserID("@noguid:test.invalid")
|
||||||
|
seedBalance(t, p, user, 500)
|
||||||
|
|
||||||
|
if _, _, err := p.DebitIdem(user, 10, "games_buyin", ""); err == nil {
|
||||||
|
t.Fatal("DebitIdem accepted an empty external id")
|
||||||
|
}
|
||||||
|
if _, _, err := p.CreditIdem(user, 10, "games_cashout", ""); err == nil {
|
||||||
|
t.Fatal("CreditIdem accepted an empty external id")
|
||||||
|
}
|
||||||
|
if got := p.GetBalance(user); got != 500 {
|
||||||
|
t.Fatalf("balance = %v, want 500", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
// Games & Economy
|
||||||
euroPlugin := plugin.NewEuroPlugin(client)
|
euroPlugin := plugin.NewEuroPlugin(client)
|
||||||
registry.Register(euroPlugin)
|
registry.Register(euroPlugin)
|
||||||
|
// The web casino's money loop. Not a plugin: it answers to Pete's poll, not
|
||||||
|
// to a Matrix message, so it has no commands and nothing to register.
|
||||||
|
plugin.StartPeteEscrowLoop(ctx, euroPlugin)
|
||||||
registry.Register(plugin.NewFlipPlugin(client))
|
registry.Register(plugin.NewFlipPlugin(client))
|
||||||
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin, dictClient))
|
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin, dictClient))
|
||||||
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
|
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
|
||||||
|
|||||||
225
pete_adventure_news_plan.md
Normal file
225
pete_adventure_news_plan.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Pete / Gogobee Adventure News Integration — quick plan
|
||||||
|
|
||||||
|
> High-level only. No assumptions about current code shape; codebase has moved
|
||||||
|
> since Pete was last touched. This is a scoping doc, not an implementation plan.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A news.parodia.dev section where Pete reports on Adventure module activity:
|
||||||
|
deaths, boss defeats, new players, notable events, and periodic generated
|
||||||
|
recaps. Distinct from the in-bot commands (`!town`, `!graveyard`, `!rivals
|
||||||
|
board`) that already surface this data on request — this makes the same kind
|
||||||
|
of information ambient and discoverable instead of command-gated.
|
||||||
|
|
||||||
|
## Relationship to existing plans
|
||||||
|
|
||||||
|
- **Converges with "Phase 16 Pete" (the deferred Pete bot) — doesn't collide.**
|
||||||
|
The news feed is the near-term, separable slice and ships first. But the
|
||||||
|
Phase 16 bot turns out to be the thing that makes the feed *first-person*: an
|
||||||
|
adventuring Pete is an embedded correspondent who files dispatches from
|
||||||
|
dungeons he's actually in. Feed first, bot later, same character. See "Pete as
|
||||||
|
a character" below.
|
||||||
|
- **Overlaps with Track E (E3 — surfacing the buried social data).** E3 builds
|
||||||
|
`!town`, `!graveyard`, `!rivals board` as pull-based bot commands. Pete's
|
||||||
|
news feed is the push-based counterpart to the same underlying data. Ideally
|
||||||
|
they share one source of truth rather than each growing its own read path.
|
||||||
|
- **Depends on C3 (World boss / "the Siege") shipping.** That's sequenced in
|
||||||
|
N6, well after N4's town work. Pete's biggest story beat — the town under
|
||||||
|
attack — doesn't exist as an event to report on until C3 lands. Fine to plan
|
||||||
|
for now, but the event-feed and stats sections can go live well before this
|
||||||
|
piece is reachable.
|
||||||
|
|
||||||
|
## Content types
|
||||||
|
|
||||||
|
1. **Event feed (low effort, high value)**
|
||||||
|
- Deaths (source, location) — same data `!graveyard` already surfaces.
|
||||||
|
- Boss defeats — first-clears, notable kills.
|
||||||
|
- New player arrivals.
|
||||||
|
- Zone first-clears / notable milestones.
|
||||||
|
- **World boss / "the Siege" (C3).** This is the marquee story beat: a
|
||||||
|
named boss camps outside town for 72h with a shared community HP pool.
|
||||||
|
Pete should report both ends of it — the announcement/start of a Siege,
|
||||||
|
and the resolution (boss falls and payout goes out, or it survives and
|
||||||
|
"loots the town," a visible pot tribute). The town-attack framing already
|
||||||
|
baked into C3's design (it's explicitly a communal threat, not just a
|
||||||
|
tougher solo fight) makes it the best fit for the generated-article
|
||||||
|
treatment, not just a log line.
|
||||||
|
- This is closest to a straightforward log-to-article pass. Good starting
|
||||||
|
point since it needs the least judgment about what's "interesting."
|
||||||
|
|
||||||
|
2. **Generated articles (higher effort, more judgment required)**
|
||||||
|
- Narrative recaps of notable happenings — a boss kill or first-clear
|
||||||
|
written up as a short story beat rather than a log line, in the same
|
||||||
|
spirit as the flavor-voice work already done elsewhere in the bot
|
||||||
|
ecosystem.
|
||||||
|
- LLM-authored via Gogobee's existing inference access, following the same
|
||||||
|
pattern Petal already established: model as a config value behind an
|
||||||
|
abstraction, not hardcoded. Pete's news writing shouldn't need its own
|
||||||
|
bespoke integration if that seam already exists.
|
||||||
|
- Event data (who, what, where, stakes) stays structured and feeds the
|
||||||
|
model as context; the model's job is prose, not fact invention. Worth
|
||||||
|
deciding early whether there's a review/approval step before publish, or
|
||||||
|
whether it goes straight to the site — the risk profile is different
|
||||||
|
between "reads a little dry" and "confidently wrong about who died."
|
||||||
|
- Open question: live per-event, or batched into a daily/weekly digest.
|
||||||
|
Batching gives editorial control over what's worth writing up instead of
|
||||||
|
reporting every routine kill, and is also cheaper on inference calls than
|
||||||
|
firing one generation per event — probably the safer default to start.
|
||||||
|
|
||||||
|
3. **Player stats**
|
||||||
|
- Needs the same leak-check the engagement plan already flags for E3:
|
||||||
|
nothing that ranks or badges donation counts (Misty/Arina), since that
|
||||||
|
could let players reverse-engineer secret NPC buffs. Tax-ledger totals
|
||||||
|
were called out as safe; donation counts were not. Any stat Pete
|
||||||
|
considers for public display should be run through that same filter
|
||||||
|
before it's greenlit, not decided ad hoc per-stat.
|
||||||
|
- Safer starting stats: playtime/level distributions, zone clear counts,
|
||||||
|
arena standings, rival board — anything already deemed room-safe under
|
||||||
|
E3's design.
|
||||||
|
|
||||||
|
## Sequencing suggestion
|
||||||
|
|
||||||
|
1. Event feed first (deaths, boss defeats, new players) — mechanical, low
|
||||||
|
judgment, reuses E3's data model.
|
||||||
|
2. Stats section next, gated through the leak-check before anything ships.
|
||||||
|
3. Generated articles last — highest creative/quality bar, and benefits from
|
||||||
|
having the event feed already running as its raw material.
|
||||||
|
|
||||||
|
## Resolved decisions
|
||||||
|
|
||||||
|
- **Public-facing — yes.** The wall isn't what protects players; the leak-check
|
||||||
|
and name-scoping are, and those apply either way. So publish publicly, but
|
||||||
|
only the public-safe subset, decided on purpose:
|
||||||
|
- **Safe to publish:** boss first-clears, Siege announcements/resolutions,
|
||||||
|
zone clear counts, arena standings, aggregate level/playtime distributions,
|
||||||
|
the rival board.
|
||||||
|
- **Community-only or omit:** anything donation-derived (Misty/Arina secret
|
||||||
|
buffs), anything that names a Matrix account, and raw per-player time series
|
||||||
|
fine-grained enough to diff and reverse-engineer a hidden buff. Public
|
||||||
|
raises the adversary from "a curious member" to "anyone with a scraper," so
|
||||||
|
the leak-check is load-bearing, not advisory.
|
||||||
|
- **Name-scoping:** report on **character names only**, never the Matrix
|
||||||
|
handle behind them. Consider a per-player opt-out of being named in
|
||||||
|
articles. Decide before launch — retroactively scrubbing an indexed/cached
|
||||||
|
public page is the URL-preview problem again.
|
||||||
|
|
||||||
|
## Pete as a character (forward tier — after the feed + section land)
|
||||||
|
|
||||||
|
The most ambitious slice, and the payoff for the whole arc: Pete stops being a
|
||||||
|
wire service that reports on others and becomes a **playable character in the
|
||||||
|
world** — the realm's embedded correspondent. Converges with the deferred
|
||||||
|
Phase 16 Pete bot. Two surfaces, both feeding the news:
|
||||||
|
|
||||||
|
1. **Solo runs.** Pete runs his own expeditions on autopilot (the existing
|
||||||
|
`runAutopilotWalk` path the sim already drives) and files first-person
|
||||||
|
dispatches: *"Your correspondent nearly didn't make it out of the
|
||||||
|
Underforge."* His deaths/level-ups/kills emit the same fact payload every
|
||||||
|
player does, so the news writes itself — and the fact-guard is trivially safe
|
||||||
|
when the subject is Pete himself.
|
||||||
|
|
||||||
|
2. **Hireable companion (class-fluid).** Players short on help can hire Pete
|
||||||
|
into their party. Unlike a player character he is **role-fluid** — he fills
|
||||||
|
the gap: no healer → Cleric, no damage → Mage, need a body up front →
|
||||||
|
Fighter. Default behavior is auto-fill the missing role, with a manual
|
||||||
|
override. When hired, that expedition becomes a dispatch too: *"Filled in as
|
||||||
|
cleric for [party] today — nobody died on my watch."*
|
||||||
|
|
||||||
|
3. **Standing rival (the house challenger).** Players can challenge Pete to a
|
||||||
|
duel any time via the existing C2 staked/no-death system — so there's always
|
||||||
|
an opponent even when the game is quiet (same trailing-case lift as hiring,
|
||||||
|
for the PvP side). He **responds to win and loss himself**, first person and
|
||||||
|
gracious either way — his losses are the better content. First time anyone
|
||||||
|
beats him is an alert. Rival results feed the news via `adventure_rival_records`.
|
||||||
|
|
||||||
|
### Identity — Pete owns Pete (corrected after reading the code)
|
||||||
|
|
||||||
|
`@pete:parodia.dev` is already an **independent bot** (pete repo) with its own
|
||||||
|
account; gogobee ignores it via `IGNORED_BOTS`. So Pete is NOT a gogobee
|
||||||
|
appservice ghost — his Matrix presence is his own bot, and **Pete owns his own
|
||||||
|
voice** (persona, templates, editorial). See `project_pete_bot_architecture`.
|
||||||
|
|
||||||
|
- **Pete's surfaces (all his bot):** the news website, his Matrix posts, and —
|
||||||
|
for the character tier — duel reactions / hire banter / replies. He's already
|
||||||
|
an in-room command bot (`!post`, `!petestats`), so he has the interactive
|
||||||
|
skeleton; the character behaviors extend it.
|
||||||
|
- **gogobee's job:** emit game-event *facts* to Pete, and provide LLM *compute*
|
||||||
|
via a generic endpoint. It does NOT voice Pete. The `📣 Pete:` broadcast lines
|
||||||
|
currently hardcoded in gogobee's flavor file are the old two-Petes situation
|
||||||
|
and should migrate to facts Pete voices.
|
||||||
|
- **LLM:** Pete stays LLM-free in his own codebase (deliberate — it was
|
||||||
|
removed). When he wants prose he calls gogobee's generic tailnet inference
|
||||||
|
endpoint. Default is template-only; LLM is opt-in per marquee beat.
|
||||||
|
|
||||||
|
Why this fits the existing guardrails:
|
||||||
|
|
||||||
|
- **It's the sanctioned difficulty lever.** A short-handed / missing-a-role
|
||||||
|
party is the trailing case; a hireable Pete lifts exactly that party without
|
||||||
|
nerfing leaders or touching monster scaling. Tune him as a competent
|
||||||
|
*below-median* member of whatever class he plays — help, never a carry.
|
||||||
|
- **Balance corpus stays clean.** Golden pins run solo, so baseline sims never
|
||||||
|
hire Pete. Hiring is a live player-help feature entirely outside the baseline;
|
||||||
|
the two paths never cross.
|
||||||
|
- **Gold sink.** Hiring costs gold per expedition — economy pressure of the same
|
||||||
|
kind the tax-ledger / lottery tuning already wants. Scarcity is a knob:
|
||||||
|
Pete "on assignment" (off covering a Siege) is both a reason he's sometimes
|
||||||
|
unavailable and a story in itself.
|
||||||
|
- **Duel economy caution.** As a standing rival he's *beatable* by design — so
|
||||||
|
his duel stakes must be bragging-rights / bounded, NOT real gold, or players
|
||||||
|
farm the house challenger for income. Tuning-pass number; flagged so it isn't
|
||||||
|
discovered as an exploit.
|
||||||
|
- **Guardrails.** Pete keeps clear of the Misty/Arina secret-buff mechanics (a
|
||||||
|
public reporter benefiting from hidden buffs and reporting outcomes is a leak
|
||||||
|
vector). His voice is his own — NOT TwinBee's.
|
||||||
|
|
||||||
|
Open tuning-pass questions (not blockers): hire cost (flat vs level-scaled);
|
||||||
|
his power scaling (fixed level vs scales-to-party); availability (always vs
|
||||||
|
cooldown / on-assignment scarcity).
|
||||||
|
|
||||||
|
## Gaps & risks register
|
||||||
|
|
||||||
|
The design's blind spot was treating the gogobee→Pete channel as internal and
|
||||||
|
trusted — it's neither once player-named characters and LLM prose flow onto a
|
||||||
|
public front page. Seven gaps, three of them must-solve-before-launch.
|
||||||
|
|
||||||
|
1. **[MUST — security] Untrusted input into LLM prompt + public HTML.** A
|
||||||
|
character name is player-controlled. It reaches (a) the LLM prompt →
|
||||||
|
prompt-injection, and (b) rendered `content` → XSS. Fix: names get escaped
|
||||||
|
before the prompt; Adventure `content` goes through Pete's **existing** RSS
|
||||||
|
sanitizer (it already strips inline scripts), never around it.
|
||||||
|
2. **[MUST] `article_url` has nowhere to point.** Schema is `NOT NULL`; a
|
||||||
|
self-hosted event has no external article. Point it at a Pete permalink
|
||||||
|
(`/adventure/<guid>` / the existing per-story reader page).
|
||||||
|
3. **[MUST] Visual identity.** No OG images → the section looks broken next to
|
||||||
|
RSS cards. Ship per-event-type art (Siege banner, graveyard mark, Pete's
|
||||||
|
avatar for his own dispatches).
|
||||||
|
4. **[RESOLVED] Selection/aggregation thresholds.** Data-derived from the prod
|
||||||
|
DB — see the thresholds section in the voice spec. Dormant community → the
|
||||||
|
risk is emptiness, not spam; near-inclusive, with grind telemetry filtered
|
||||||
|
out and achievements rarity-gated.
|
||||||
|
5. **[RESOLVED — security] Opt-out.** Default-featured (informed, announced at
|
||||||
|
launch), one gogobee command backed by a `news_optout` table (mirrors the
|
||||||
|
existing `shade_optout`). Enforced at emit time. Opt-out **anonymizes, not
|
||||||
|
deletes** ("an adventurer…"). Retroactive: `POST /ingest/adventure/scrub`
|
||||||
|
anonymizes existing rows; whole section is `noindex` to bound the
|
||||||
|
cached-forever problem.
|
||||||
|
6. **[RESOLVED — security] Kill-switch.** Global `adventure_news_enabled` flag
|
||||||
|
in gogobee kills emission at the source (generation is gogobee-side, so this
|
||||||
|
is the real switch). Per-story **admin delete on Pete**, reusing the OIDC
|
||||||
|
`adminSubs` gate that already guards `/status`.
|
||||||
|
7. **[RESOLVED] Cold-start backfill.** Guarded one-shot in gogobee replays the
|
||||||
|
back-catalog (realm-firsts, deaths, notable clears, single-holder
|
||||||
|
achievements) through the emit path. Backdated `published_at`, dispatch tone,
|
||||||
|
**no web-push**. Idempotent on `guid`; kept as a marked one-shot (not deleted
|
||||||
|
at close-out) so fresh deploys don't re-ghost-town. At this volume, backfill
|
||||||
|
is most of the launch content, not a nice-to-have.
|
||||||
|
|
||||||
|
## Open questions to settle before scoping further
|
||||||
|
|
||||||
|
- Does Pete write live off events, or on a batch cadence?
|
||||||
|
- Single source of truth for this data shared with E3's bot commands, or a
|
||||||
|
separate read path for the news site?
|
||||||
|
- Voice: does Pete have an established voice yet, or does this news section
|
||||||
|
define it?
|
||||||
|
- Which model tier for article generation — same one Petal's conversational
|
||||||
|
side uses, or a separate config entry, given the different latency/quality
|
||||||
|
tradeoffs of a batch job versus interactive chat?
|
||||||
224
pete_adventure_news_voice.md
Normal file
224
pete_adventure_news_voice.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Pete × Parodia — Adventure section voice + architecture spec
|
||||||
|
|
||||||
|
> Companion to `pete_adventure_news_plan.md`. Pete is an independent LLM-free bot
|
||||||
|
> that OWNS his voice; gogobee is the game-event source + generic LLM compute.
|
||||||
|
> Voice: a warm, friendly reporter (owner's call). See
|
||||||
|
> `project_pete_bot_architecture` memory.
|
||||||
|
|
||||||
|
## Architecture (the load-bearing part)
|
||||||
|
|
||||||
|
- **Pete owns voice, authoring, identity, publishing.** Persona, templates,
|
||||||
|
editorial, the `@pete:parodia.dev` account. Pete decides what is said and says
|
||||||
|
it. Pete's codebase has **zero** Ollama/vLLM connectivity, by design.
|
||||||
|
- **gogobee owns game events + raw LLM compute.** Source of truth for what
|
||||||
|
happened; the only codebase that touches the models.
|
||||||
|
- **gogobee → Pete: structured event *facts*** (the payload below), NOT finished
|
||||||
|
prose. gogobee is compute, not ghostwriter.
|
||||||
|
- **Pete → gogobee: a generic, voice-agnostic inference endpoint.** Prompt in,
|
||||||
|
completion out — gogobee never sees "Pete." Pete builds the voiced prompt in
|
||||||
|
his own codebase and calls it. Reusable for Pete's other channels later.
|
||||||
|
- **Network:** both directions ride the Headscale tailnet (Parodia ↔ LAN box).
|
||||||
|
gogobee's inference endpoint is tailnet-bound + bearer-authed; Ollama/vLLM
|
||||||
|
stays `localhost` on the LAN box, never on the tailnet.
|
||||||
|
|
||||||
|
## Voice — Pete the friendly reporter
|
||||||
|
|
||||||
|
Pete is a **reporter, and a warm one** — think a beloved local newscaster who
|
||||||
|
genuinely knows everyone in town and is glad to see you. Journalistic bones
|
||||||
|
(clear headline, a who/what/where/when lede, gets the facts right) delivered
|
||||||
|
with a personable, first-person warmth. He roots for the community, celebrates
|
||||||
|
wins, mourns losses gently, and welcomes newcomers by name. Conversational,
|
||||||
|
never snarky. Friendly, not a caps-lock hype-man.
|
||||||
|
|
||||||
|
- Celebratory win: *"What a turnout — the town held the line tonight."*
|
||||||
|
- Gentle loss: *"Sad news to pass along today."*
|
||||||
|
- Warm welcome: *"Say hello if you see them out there."*
|
||||||
|
- His own sign-off: a short reporter's tag closes bigger pieces — *"Reporting
|
||||||
|
from {region}, this is Pete."*
|
||||||
|
|
||||||
|
Tiering is by **prefix + placement**, not by shouting:
|
||||||
|
- **PRIORITY** (rare, live): Sieges, world-firsts, deaths, a new arrival, the
|
||||||
|
first time Pete loses a duel. Posted immediately.
|
||||||
|
- **BULLETIN** (batched daily digest): everything else worth noting.
|
||||||
|
|
||||||
|
Warmth carries the register, not exclamation marks. A Siege is urgent but Pete
|
||||||
|
is steady and rallying, not panicked: *"Folks, this is the big one — and the
|
||||||
|
whole community's needed. Let's rally."*
|
||||||
|
|
||||||
|
## Hard rules (govern templates AND any LLM prose)
|
||||||
|
|
||||||
|
1. **Character names only.** Never the Matrix handle behind a character.
|
||||||
|
2. **No donation-derived anything** (Misty/Arina secret buffs).
|
||||||
|
3. **Facts are supplied, never invented.** Names/numbers/outcomes come only from
|
||||||
|
the payload `actors[]` allow-list; the fact-guard rejects anything else.
|
||||||
|
4. **Public front-page surface.** Safe-subset rule from the plan applies.
|
||||||
|
|
||||||
|
## Fact payload (gogobee → Pete)
|
||||||
|
|
||||||
|
Flat, pre-sanitized. `actors[]` is the ONLY set of names allowed in output.
|
||||||
|
Character names are player-controlled → sanitize before they enter a prompt or
|
||||||
|
rendered HTML (see plan gap #1).
|
||||||
|
|
||||||
|
```
|
||||||
|
event_type siege_start | siege_win | siege_loss | boss_first | boss_kill |
|
||||||
|
zone_first | zone_clear | death | retreat | arrival | standings |
|
||||||
|
rival_result | pete_duel_loss | pete_duel_win | milestone
|
||||||
|
// GUID prefix == event_type (permalink derives its label from it)
|
||||||
|
tier priority | bulletin
|
||||||
|
actors[] ["Brannigan", ...] // allow-list for output
|
||||||
|
subject "Brannigan"
|
||||||
|
opponent "Kif" // duels / rivalries
|
||||||
|
boss "The Hollow King"
|
||||||
|
zone "Dragon's Lair"
|
||||||
|
region "the Underforge"
|
||||||
|
level 14
|
||||||
|
count 37 // defenders, clears, etc.
|
||||||
|
outcome won | lost | ...
|
||||||
|
stakes "72h" / figure
|
||||||
|
occurred_at unix
|
||||||
|
```
|
||||||
|
|
||||||
|
## Templates (Pete the friendly reporter)
|
||||||
|
|
||||||
|
Format: `TIER · cadence`. `content` (article body) is optional LLM prose via the
|
||||||
|
gogobee endpoint; headline/lede are always template — deterministic and safe.
|
||||||
|
|
||||||
|
### siege_start — PRIORITY · live
|
||||||
|
- **headline:** `Breaking: {boss} is marching on the town.`
|
||||||
|
- **lede:** `Folks, this is the big one — {boss} has camped outside the gates and the whole community's needed to turn it back. You've got {stakes}. Let's rally.`
|
||||||
|
|
||||||
|
### siege_win — PRIORITY · live
|
||||||
|
- **headline:** `The town holds! {boss} turned back.`
|
||||||
|
- **lede:** `What a turnout — {count} defenders stood shoulder to shoulder and sent {boss} packing. Spoils are going out now. Proud of you all.`
|
||||||
|
|
||||||
|
### siege_loss — PRIORITY · live
|
||||||
|
- **headline:** `Heavy news: {boss} broke through.`
|
||||||
|
- **lede:** `We gave it everything, but {boss} got past the gates and took its tribute. We'll be ready next time — heads up, everyone.`
|
||||||
|
|
||||||
|
### boss_first — PRIORITY · live
|
||||||
|
- **headline:** `First ever: {subject} brings down {boss}.`
|
||||||
|
- **lede:** `History in {region} today — {subject} is the first anyone's seen clear {boss}. Nobody had done it before. Hats off.`
|
||||||
|
|
||||||
|
### zone_first — PRIORITY (realm-first) · live
|
||||||
|
- **headline:** `{zone} cleared for the very first time.`
|
||||||
|
- **lede:** `{subject} made it through {zone} in {region}{, at level {level}}. Nicely done.`
|
||||||
|
|
||||||
|
### zone_clear — BULLETIN (repeat) · batch
|
||||||
|
- **headline:** `{subject} clears {zone}.`
|
||||||
|
- **lede:** same as zone_first (shared).
|
||||||
|
- gogobee emits `zone_first` (priority) only for the realm's first clear of a
|
||||||
|
zone and `zone_clear` (bulletin) for every later clear, so a repeat is never
|
||||||
|
filed under "first ever" — headline, permalink label, and emblem all match.
|
||||||
|
|
||||||
|
### boss_kill — BULLETIN · batch
|
||||||
|
- **headline:** `{subject} takes down {boss} again.`
|
||||||
|
- **lede:** `Another clean run in {zone} today. Routine for {subject} by now — but still worth a nod.`
|
||||||
|
|
||||||
|
### death — PRIORITY · live
|
||||||
|
- **headline:** `We lost {subject} in {zone}.`
|
||||||
|
- **lede:** `Sad news to pass along: {subject} fell at level {level} in {zone}. The graveyard's a little fuller tonight. Rest easy.`
|
||||||
|
|
||||||
|
### arrival — BULLETIN · batch
|
||||||
|
- **headline:** `Welcome to the realm, {subject}!`
|
||||||
|
- **lede:** `A new {class_race} just walked through the gates. Say hello if you see them out there.`
|
||||||
|
|
||||||
|
### standings — BULLETIN · batch
|
||||||
|
- **headline:** `The rival board's been shaken up.`
|
||||||
|
- **lede:** `{subject} is on the move — here's where the standings sit today.`
|
||||||
|
|
||||||
|
### rival_result — BULLETIN · batch (Pete uninvolved)
|
||||||
|
- **headline:** `{subject} settles the score with {opponent}.`
|
||||||
|
- **lede:** `Their duel went {subject}'s way today, and the board reflects it. Good match, you two.`
|
||||||
|
|
||||||
|
### pete_duel_loss — PRIORITY (first-ever) / BULLETIN · live · FIRST PERSON
|
||||||
|
- **headline (first-ever):** `You got me, {subject}.`
|
||||||
|
- **headline (repeat):** `{subject} got the better of me again.`
|
||||||
|
- **lede:** `Credit where it's due — {subject} beat me fair and square. Good duel. I'll want a rematch when you're ready.`
|
||||||
|
|
||||||
|
### pete_duel_win — BULLETIN · batch · FIRST PERSON
|
||||||
|
- **headline:** `Held my ground against {subject} today.`
|
||||||
|
- **lede:** `Closer than the record will show, honestly — {subject} pushed me. Rematch whenever you like.`
|
||||||
|
|
||||||
|
### retreat — BULLETIN · batch
|
||||||
|
- **headline:** `{subject} backed out of {zone}.`
|
||||||
|
- **lede:** `{subject} turned around {N days in} — {zone} got the better of them
|
||||||
|
this time, at level {level}, and they made the call to walk out rather than
|
||||||
|
push it. Everybody came home breathing, which is the bit that counts. That
|
||||||
|
dungeon'll still be there next week.`
|
||||||
|
- An expedition that ended with the player ALIVE (a solo clock-out retreat, or a
|
||||||
|
party the turn engine broke off). `count` = the day they reached.
|
||||||
|
- Emitted from gogobee's one run-loss chokepoint, gated on the reason: a **death**
|
||||||
|
files its own priority dispatch and must never ALSO appear as a retreat, and an
|
||||||
|
**idle reap** is never filed at all — a player who closed their laptop did not
|
||||||
|
flee anything, and saying so by name would be a lie about a person.
|
||||||
|
- Warm, not a failure notice. Lead with everyone coming home.
|
||||||
|
|
||||||
|
### milestone — BULLETIN · batch
|
||||||
|
- **headline:** `{subject} hits {milestone}.`
|
||||||
|
- **lede:** `One for the books — {subject} just reached {milestone}. The long road continues.`
|
||||||
|
|
||||||
|
## Optional LLM prose (Pete authors, gogobee computes)
|
||||||
|
|
||||||
|
For the PRIORITY beats only, Pete MAY expand `content` into a short friendly
|
||||||
|
write-up in his reporter voice. Pete builds the prompt (his persona + the fact
|
||||||
|
payload) in Pete's
|
||||||
|
codebase and calls gogobee's generic inference endpoint; gogobee returns raw
|
||||||
|
text; Pete posts it. gogobee never authors — it computes.
|
||||||
|
|
||||||
|
Given you removed LLM from Pete deliberately, template-only is the safe default
|
||||||
|
and the data supports it (see thresholds — tiny volume). LLM prose is opt-in per
|
||||||
|
beat, not the baseline.
|
||||||
|
|
||||||
|
### Fallback ladder (never blocks publish)
|
||||||
|
Headline + lede are always template-derived, so Pete publishes regardless.
|
||||||
|
`content` LLM expansion is best-effort:
|
||||||
|
1. Endpoint disabled in Pete config → template body.
|
||||||
|
2. Endpoint unreachable / timeout → template body.
|
||||||
|
3. Empty or malformed completion → template body.
|
||||||
|
4. Fact-guard fails (a name not in `actors[]`, or a number that contradicts the
|
||||||
|
payload) → template body. Guard runs in Pete, on the completion.
|
||||||
|
|
||||||
|
## Selection thresholds (data-derived, prod DB July 2026)
|
||||||
|
|
||||||
|
Measured against live prod (`millenia`, `data/gogobee.db`). Community is
|
||||||
|
**dormant** — 4 players ever (L20–49). Risk is *emptiness*, not spam;
|
||||||
|
near-inclusive.
|
||||||
|
|
||||||
|
Observed (Apr–Jul 2026): deaths ~3 total; boss kills 23 / 62 days; milestones
|
||||||
|
43 / 60 days; achievements 181 (only flood-prone type); arena runs 82 / rivals
|
||||||
|
6; Sieges 0 ever.
|
||||||
|
|
||||||
|
**Negative filter (matters most):** high-volume tables are grind telemetry, NOT
|
||||||
|
news — `adventure_activity_log` successes (mining/fishing/foraging) and
|
||||||
|
expedition-log `interrupt|walk|harvest|ambient|rest|night`. Never publish. Only
|
||||||
|
`milestone`/`recap` entry types, deaths, and boss/zone firsts surface.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- **PRIORITY (live, individual):** realm-first zone clear, realm-first boss kill,
|
||||||
|
any Siege start/resolve, new-player arrival, any death, first-ever Pete duel loss.
|
||||||
|
- **BULLETIN (daily digest):** repeat boss kills, repeat zone clears,
|
||||||
|
standings/rival shifts, Pete duel wins.
|
||||||
|
- **Rarity-gated:** an achievement publishes only if held by a *single* player.
|
||||||
|
- **Never published:** harvest/mining/fishing outcomes, expedition
|
||||||
|
interrupt/walk/ambient/rest noise.
|
||||||
|
- **Aggregation:** collapse to a count line only if the same `(event_type,
|
||||||
|
target)` recurs **≥3×** in a digest window (historically never triggers).
|
||||||
|
- **Cadence:** bulletin = daily digest; priority fires live.
|
||||||
|
|
||||||
|
## Delivery seam
|
||||||
|
|
||||||
|
### gogobee → Pete: event facts
|
||||||
|
- Transport: gogobee POSTs the fact payload to Pete over the tailnet
|
||||||
|
(or Pete's HTTPS ingest), bearer-authed.
|
||||||
|
- Idempotent on a stable event key (`siege_win:<ts>` etc.) so retries are no-ops.
|
||||||
|
- gogobee owns delivery reliability: queue + retry so a Pete restart loses
|
||||||
|
nothing.
|
||||||
|
- Adventure output is Pete-on-Matrix + the website. gogobee must NOT also
|
||||||
|
announce these itself — the `📣 Pete:` broadcast lines currently hardcoded in
|
||||||
|
gogobee's flavor file should migrate to facts Pete voices (see plan).
|
||||||
|
|
||||||
|
### Pete → gogobee: generic inference
|
||||||
|
- Pete POSTs `{prompt, params}` to gogobee's tailnet inference endpoint,
|
||||||
|
bearer-authed. Completion returned as raw text. gogobee proxies to
|
||||||
|
`localhost` Ollama/vLLM.
|
||||||
|
- Voice-agnostic: gogobee has no Adventure/Pete-specific logic on this path.
|
||||||
Reference in New Issue
Block a user