9 Commits

Author SHA1 Message Date
prosolis
94077a47ae mischief: a contract that never landed shouldn't protect its target
The 24h cooldown and the one-boss-a-week cap both keyed off any resolved
contract, and a fizzle resolves. So a target could have a friend point a
grunt at them, extract, and buy a day of immunity for the fizzle rake.
Both caps now only count contracts that were actually delivered.

Delivery also ran a full combat against the target's sheet without their
advUserLock. hasActiveCombatSession only sees the turn engine — the
target's own autopilot walk resolves its fights inline under that lock
and reports no session, so a delivery could race it and lose a fight's
worth of HP writes.

Also: don't tell a buyer a rival beat them when the insert simply failed.
2026-07-13 20:21:03 -07:00
prosolis
8fc5a82b83 mischief: you can now pay to have someone's expedition ruined
Mischief Makers M1 — the core engine, Matrix-only. `!mischief send elite @user`
debits the buyer, tells the games room a hit is out, and an hour later a monster
from the target's own level bracket walks into whatever dungeon they're in.
Survive it and they keep a cut of the money and the buyer is named; don't, and
they wake up on a cart home.

The monster comes from the target's bracket zone pool, not the arena ladder and
not the dungeon they happen to be standing in — the same selection code the M0
pricing sweep ran through, so the fee table can't drift away from the fight it
priced.

Three things that are load-bearing and don't look it:

  * Survival is read off the target's HP, not PlayerWon. The engine's timeout is
    a retreat, not a lethal blow — somebody 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.

  * Nobody dies for money, and that includes the party. The delivery skips
    closeOutZoneWin/Loss (the fight is extrinsic to the dungeon — crediting it
    would let a buyer unlock the target's kill-gated resources for them), so
    nothing else floors a downed seat. Without floorMischiefRoster on BOTH
    outcomes, a member the leader outlived is left alive at 0 HP, which every
    `HPCurrent <= 0` gate reads as broken rather than dead.

  * 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 the
    same victim would both pass an in-code test. The loser is refunded.

Payouts are a percentage of the base fee, never of what the buyer actually paid —
the sign surcharge is a pure sink. Capped at 75%, so a survival purse is always
strictly less than the outlay and collusion loses to !baltransfer, which is free.
That cap is the entire anti-collusion story; no danger multiplier needed.

A crash between claiming a contract and closing it out used to be unrecoverable
in the design: the row would strand, the target could never be targeted again,
and the buyer's money was gone. The stale sweep refunds those in full — that one
is our fault, not a bet they lost.

Contract timestamps bind as Go time.Time, never CURRENT_TIMESTAMP. The driver
stores RFC3339 and SQLite's own stamp is space-separated; the two compare
lexicographically wrong.

Pete learns four mischief_* event types in a separate commit, and has to deploy
BEFORE this does — an unknown event_type is a 400, which retries and then parks
the bulletin forever.
2026-07-13 20:03:20 -07:00
prosolis
2ab6e7843a roster: a quietly-succeeding ticker looks exactly like a dead one
The push logged nothing on success, so during the first deploy an empty board
and a silent log were indistinguishable from a ticker that never started — and
the debug went looking for a bug that wasn't there. (The snapshot was simply
2 minutes out; the first tick hadn't fired yet.)

Logging every push at INFO would be noise forever. Log the transitions instead:
the first time it works, the moment it breaks, and when it recovers.
2026-07-13 18:19:16 -07:00
prosolis
ced75786b9 news: tell Pete who's out there right now, not just who died
We only ever told Pete about outcomes. Nothing emitted when an expedition
*started*, which is why the two bored adventurers walked into dungeons and the
news feed said nothing at all — it wasn't broken, it had nothing to say.

Two halves:

A roster snapshot, pushed every 2 minutes. Deliberately NOT on the durable fact
queue: a fact is history and losing it loses it forever, so it retries. A
snapshot is a photograph of the present, and a retried one is a lie — by the
time it lands, she's moved. The next tick carries the truth. That's also what
lets Pete's staleness timer work: if we stay down, nothing arrives, and the
board stops claiming to be live instead of insisting forever that Josie is
still in holymachina.

And a "departure" bulletin when a bored adventurer lets itself out.

The snapshot omits opted-out players rather than anonymizing them, and carries a
board token distinct from every event token, so a standing row can't become the
key that links a player's dispatches back together.

The player_meta scan folds last_player_action_at/created_at in Go instead of
COALESCE()ing in SQL — modernc rebuilds time.Time from the declared column type
and COALESCE erases it. A failed scan here would publish an empty board and
every adventurer would vanish from the page.
2026-07-13 18:05:49 -07:00
prosolis
1cbd68a718 news: Pete stops repeating what TwinBee just said in the room
Priority tier is the only thing that makes Pete post a live Matrix beat,
and the only two priority facts gogobee filed - zone_first and death -
are both moments TwinBee narrates in-room as they happen. Every live beat
was an echo. File them as bulletins: still on the site, still in the
daily digest, no second telling to the people who were there.
2026-07-12 22:01:09 -07:00
prosolis
e377e0c85c boredom: the idle clock has to be seeded, or the deploy empties the town
last_player_action_at is NULL for every existing row on the deploy that
adds it, and loadBoredomCandidates COALESCEs the NULL onto created_at.
created_at is account age, not an idle clock: for anyone who has been
playing for a month it is a month old. So the first tick after restart
would read the entire server as idle-since-creation and march all of
them into a dungeon thirty minutes later, coins debited, including the
player who typed a command a minute before the deploy.

Seed the column once from last_active_at instead — the best "did
something recently" proxy that exists at deploy time, recent for the
regulars and stale for the lapsed. It stays unusable as the clock itself
(the autopilot bumps it), which is why this is a one-time seed and not a
fallback in the query. Re-running on every boot is a no-op, and it skips
rows that already have last_boredom_at set, so a restart mid-boredom
can't hand a bored character a fresh idle clock off its own autopilot
writes and un-bore it permanently.

Two smaller ones in the same clock:

- a pending prompt counted as an action regardless of ExpiresAt, and
  nothing sweeps p.pending. One offered-and-ignored treasure prompt and
  every idle remark that player ever made would read as tending their
  adventurer, forever.
- the command test ran 50 IsCommand passes over the body on every
  message the bot sees, in every room. One tokenise-and-look-up does the
  same job.
2026-07-12 19:08:22 -07:00
prosolis
7c379b298c adventure: bored adventurers go into dungeons on their own
A player who stops tending their adventurer doesn't stop having one. After
24h with no action against Adventure, the character gets restless and leaves
on an expedition by itself: the easiest zone its level band allows, the
cheapest supplies it can afford, and whatever gear was already on the rack.

Everything downstream of the start was already autonomous — the autopilot
walks rooms, drives elite and boss fights on the turn engine, camps, harvests
and picks forks. The only thing that ever needed a human was `!expedition
start`, so that's all this adds: a 30m ticker plus a clock.

It never buys or equips anything, and that is the whole mechanic: a neglected
adventurer grinds half-starved runs on rusting gear and comes home taxed. The
prodexercise killed an L4 mage four rooms in on its first run.

The clock is a new column. Every existing timestamp is unusable: last_active_at
is auto-bumped by saveAdvCharacter (the autopilot would refresh a bored
character's own idle clock), loadAdvDailyActivity counts the autopilot's own
expedition logs, and user_stats.updated_at is chat presence, not a game action.
last_player_action_at is written only by markPlayerAction, from a real player
action against Adventure — any interface, not just Matrix.

Raid zones (raidContentWarning: the party-tuned T5 bosses with a 0% solo clear)
are avoided while anything else is in band. At L13+ they're all that's left, and
the adventurer goes in anyway and loses. That's intended.

dnd_expedition.boredom + isBoredomDriven stop a run nobody asked for from
shielding an absent player from the idle reaper or holding their streak. A
manually-started expedition still holds it while the autopilot walks it.

Robbie visits and pays silently for idle players — he was going to file a daily
public bulletin about people who stopped playing weeks ago.

Note for anyone touching the time-scanning queries here: modernc.org/sqlite
rebuilds a time.Time from the column's declared type, and COALESCE()/MAX() erase
it. playerIsIdle fails open, so a broken scan there declares the whole server
idle. Both it and lastExpeditionByZone select declared columns and fold in Go,
and the tests seed real rows so the scan actually executes.
2026-07-12 18:57:50 -07:00
prosolis
a533e106c2 appservice: keep the crypto method set on the wrapped state store
The lazy store embedded appservice.StateStore, but the client's store is
also type-asserted to crypto.StateStore -- a wider interface -- and
NewCryptoHelper refuses to start when that assertion fails. Embedding the
narrower interface dropped GetEncryptionEvent and FindSharedRooms from the
concrete type, so the bot died on startup with "the client state store
must implement crypto.StateStore".

Embed both, assert both at compile time, and resolve GetEncryptionEvent
lazily like IsEncrypted since the crypto machine reads megolm rotation
settings through it.
2026-07-12 17:31:19 -07:00
prosolis
2482433896 appservice: stop sending plaintext into encrypted rooms
Without /sync nothing backfills the state store, so a room the bot had
not yet heard from looked unencrypted -- IsEncrypted answers false with
no error -- and any message the bot started on its own (ambient DMs,
expedition beats, briefings) went out in the clear. Under /sync the
cryptohelper registered StateStoreSyncHandler and the initial sync
carried every joined room's state, so the store was warm before the bot
ever spoke; the appservice port replicated the crypto plumbing but not
that backfill.

Resolve encryption and membership from the server on first read instead,
and refuse the send when they cannot be resolved: a message that fails is
recoverable, a plaintext one that has landed is not. Members are resolved
the same way, since an unfetched room shares the group session with
nobody.
2026-07-12 17:27:28 -07:00
25 changed files with 4126 additions and 99 deletions

View File

@@ -7,7 +7,6 @@ import (
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/rs/zerolog"
@@ -107,7 +106,19 @@ func newAppserviceSession(cfg Config) (*Session, error) {
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
}
// Resolve room state (is it encrypted, who is in it) from the server on first
// use, since there is no /sync to backfill it. Must be installed before the
// first BotClient() call: makeClient copies as.StateStore into the client, and
// caches the client.
inner, ok := as.StateStore.(innerStateStore)
if !ok {
return nil, fmt.Errorf("appservice state store %T does not implement crypto.StateStore", as.StateStore)
}
store := newLazyStateStore(inner)
as.StateStore = store
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
store.client = client
// Assert the device via ?org.matrix.msc3202.device_id= on E2EE requests, and
// satisfy cryptohelper.Init's syncer check: with this set, Init permits a nil
// Syncer (we drive the crypto machine from transactions, not /sync). The param
@@ -172,29 +183,6 @@ func newAppserviceSession(cfg Config) (*Session, error) {
mach.HandleMemberEvent(ctx, evt)
})
// Without /sync there is no state backfill, so the client's StateStore starts
// empty. Before we hand off a decrypted event (whose reply may need to be
// encrypted), resolve the room once: mark it encrypted and populate its member
// list. Otherwise outbound sends go plaintext (IsEncrypted=false) and, worse,
// the group session is shared to nobody (GetRoomJoinedOrInvitedMembers empty)
// so recipients can't decrypt the bot's replies.
var resolved sync.Map // roomID -> struct{}, resolved once
resolveRoom := func(ctx context.Context, roomID id.RoomID) {
if _, done := resolved.LoadOrStore(roomID, struct{}{}); done {
return
}
var enc event.EncryptionEventContent
if err := client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc); err == nil && enc.Algorithm != "" {
_ = as.StateStore.SetEncryptionEvent(ctx, roomID, &enc)
}
if members, err := client.Members(ctx, roomID); err == nil {
_ = as.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk)
} else {
slog.Warn("appservice: failed to fetch room members; will retry", "room", roomID, "err", err)
resolved.Delete(roomID) // allow a later event to retry
}
}
// recoverSession handles an event whose megolm session we don't have yet. The
// keys are often merely in flight (a to-device m.room_key racing the room
// event), so wait briefly; if they never land, ask the sender to re-share and
@@ -236,7 +224,6 @@ func newAppserviceSession(cfg Config) (*Session, error) {
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
resolveRoom(ctx, evt.RoomID)
decrypted, err := ch.Decrypt(ctx, evt)
if err != nil {
if errors.Is(err, cryptohelper.NoSessionFound) {

164
internal/bot/statestore.go Normal file
View File

@@ -0,0 +1,164 @@
package bot
import (
"context"
"errors"
"fmt"
"sync"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// lazyStateStore resolves room state from the homeserver on first use instead of
// waiting for it to arrive over a sync.
//
// The appservice has no /sync, so the state store only ever learns a room is
// encrypted from an m.room.encryption event pushed in a transaction — which for
// an already-configured room never happens again. Anything the bot says on its
// own initiative (ambient DMs, expedition beats, briefings) therefore reached
// SendMessageEvent with IsEncrypted=false and went out as plaintext into an
// encrypted room. The state store being in-memory made every restart a fresh
// chance to leak.
//
// Both reads the send path depends on are resolved here on the first miss and
// cached: whether the room is encrypted, and who to share the group session with
// (an empty member list encrypts to nobody, which is its own outage). Neither
// read is allowed to fail open — a lookup that cannot be resolved returns an
// error, which aborts the send. A failed message is recoverable; a plaintext one
// that has already landed in an encrypted room is not.
type lazyStateStore struct {
// Embeds both interfaces the store is consumed through: the appservice
// dispatches state updates through the first, and the crypto machine type-
// asserts the client's store to the second (cryptohelper.NewCryptoHelper
// rejects a store that fails the assertion). Embedding only the appservice
// interface silently drops crypto's extra methods from the concrete type.
innerStateStore
client *mautrix.Client
mu sync.Mutex
locks map[id.RoomID]*sync.Mutex
// Rooms whose m.room.encryption state we have asked the server about. The
// underlying store cannot distinguish "not encrypted" from "never heard of
// it", so the distinction is tracked here.
encryptionResolved map[id.RoomID]bool
}
// innerStateStore is the full method set the wrapped store must provide.
type innerStateStore interface {
appservice.StateStore
crypto.StateStore
}
// The wrapper is consumed through both, and losing either one is a startup
// failure rather than a build failure without these.
var (
_ appservice.StateStore = (*lazyStateStore)(nil)
_ crypto.StateStore = (*lazyStateStore)(nil)
)
func newLazyStateStore(inner innerStateStore) *lazyStateStore {
return &lazyStateStore{
innerStateStore: inner,
locks: make(map[id.RoomID]*sync.Mutex),
encryptionResolved: make(map[id.RoomID]bool),
}
}
// lockRoom serialises resolution per room so a burst of sends into a cold room
// makes one request rather than one per message.
func (s *lazyStateStore) lockRoom(roomID id.RoomID) func() {
s.mu.Lock()
lock, ok := s.locks[roomID]
if !ok {
lock = &sync.Mutex{}
s.locks[roomID] = lock
}
s.mu.Unlock()
lock.Lock()
return lock.Unlock
}
func (s *lazyStateStore) isEncryptionResolved(roomID id.RoomID) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.encryptionResolved[roomID]
}
func (s *lazyStateStore) markEncryptionResolved(roomID id.RoomID) {
s.mu.Lock()
defer s.mu.Unlock()
s.encryptionResolved[roomID] = true
}
// IsEncrypted answers from the underlying store once the room's encryption state
// has been resolved, fetching it from the server the first time.
func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) {
if !s.isEncryptionResolved(roomID) {
unlock := s.lockRoom(roomID)
defer unlock()
if !s.isEncryptionResolved(roomID) {
var enc event.EncryptionEventContent
err := s.client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc)
switch {
case err == nil:
if enc.Algorithm != "" {
if err := s.innerStateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil {
return false, fmt.Errorf("cache encryption state for %s: %w", roomID, err)
}
}
case errors.Is(err, mautrix.MNotFound):
// No m.room.encryption at all: the room really is unencrypted.
default:
// Unresolved. Refuse rather than guess "unencrypted" and leak.
return false, fmt.Errorf("resolve encryption state for %s: %w", roomID, err)
}
s.markEncryptionResolved(roomID)
}
}
return s.innerStateStore.IsEncrypted(ctx, roomID)
}
// GetEncryptionEvent is how the crypto machine reads a room's megolm settings
// (session rotation period, etc). It needs the same resolution as IsEncrypted,
// since an unresolved room would otherwise report no encryption event.
func (s *lazyStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) {
if _, err := s.IsEncrypted(ctx, roomID); err != nil {
return nil, err
}
return s.innerStateStore.GetEncryptionEvent(ctx, roomID)
}
// GetRoomJoinedOrInvitedMembers backs megolm session sharing. Without a sync
// there is no member backfill, so an unfetched room would report zero members
// and the bot would encrypt to an audience of nobody.
func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) {
fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID)
if err != nil {
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
}
if !fetched {
unlock := s.lockRoom(roomID)
defer unlock()
if fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID); err != nil {
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
} else if !fetched {
members, err := s.client.Members(ctx, roomID)
if err != nil {
return nil, fmt.Errorf("fetch members of %s: %w", roomID, err)
}
// No membership filter, so this also marks the room as fetched.
if err := s.innerStateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil {
return nil, fmt.Errorf("cache members of %s: %w", roomID, err)
}
}
}
return s.innerStateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID)
}

View File

@@ -0,0 +1,195 @@
package bot
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/id"
)
// fakeHS serves the two endpoints the lazy store resolves against, counting hits
// so we can assert it caches instead of re-asking on every send.
type fakeHS struct {
encryptionStatus int
encryptionBody string
membersBody string
encryptionHits atomic.Int32
memberHits atomic.Int32
}
func (f *fakeHS) start(t *testing.T) *mautrix.Client {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/state/m.room.encryption"):
f.encryptionHits.Add(1)
w.WriteHeader(f.encryptionStatus)
_, _ = w.Write([]byte(f.encryptionBody))
case strings.HasSuffix(r.URL.Path, "/members"):
f.memberHits.Add(1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(f.membersBody))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
client, err := mautrix.NewClient(srv.URL, "@bot:example.org", "token")
if err != nil {
t.Fatalf("new client: %v", err)
}
return client
}
func newTestStore(t *testing.T, hs *fakeHS) *lazyStateStore {
t.Helper()
store := newLazyStateStore(mautrix.NewMemoryStateStore().(innerStateStore))
store.client = hs.start(t)
return store
}
// The store is handed to the crypto helper, which type-asserts it and refuses to
// start if it comes up short. That assertion is the whole bot's startup path, so
// pin it here rather than discovering it as a crash loop in prod.
func TestStoreSatisfiesCryptoHelper(t *testing.T) {
store := newTestStore(t, &fakeHS{})
client := store.client
client.StateStore = store
dir := t.TempDir()
if _, err := cryptohelper.NewCryptoHelper(client, []byte("test"), filepath.Join(dir, "crypto.db")); err != nil {
t.Fatalf("crypto helper rejected the state store, so the bot would not start: %v", err)
}
}
const roomID = id.RoomID("!room:example.org")
// The regression: a room configured as encrypted before the process started. No
// transaction ever re-announces it, so the store must go ask.
func TestIsEncryptedResolvesFromServer(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusOK,
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
}
store := newTestStore(t, hs)
enc, err := store.IsEncrypted(context.Background(), roomID)
if err != nil {
t.Fatalf("IsEncrypted: %v", err)
}
if !enc {
t.Fatal("room is encrypted on the server but IsEncrypted said false — this is the plaintext leak")
}
}
// A room with no m.room.encryption really is plaintext, and must not re-fetch.
func TestIsEncryptedCachesUnencrypted(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusNotFound,
encryptionBody: `{"errcode":"M_NOT_FOUND","error":"Event not found."}`,
}
store := newTestStore(t, hs)
for i := 0; i < 3; i++ {
enc, err := store.IsEncrypted(context.Background(), roomID)
if err != nil {
t.Fatalf("IsEncrypted: %v", err)
}
if enc {
t.Fatal("unencrypted room reported as encrypted")
}
}
if got := hs.encryptionHits.Load(); got != 1 {
t.Errorf("expected the M_NOT_FOUND to be cached, got %d fetches", got)
}
}
// The important half: an unresolvable room must fail the send, not quietly
// answer "not encrypted" and let the message out in the clear.
func TestIsEncryptedFailsClosed(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusInternalServerError,
encryptionBody: `{"errcode":"M_UNKNOWN","error":"oops"}`,
}
store := newTestStore(t, hs)
if _, err := store.IsEncrypted(context.Background(), roomID); err == nil {
t.Fatal("a failed lookup returned no error; the send would proceed in plaintext")
}
// The failure must not poison the cache: a later send has to retry.
hs.encryptionStatus = http.StatusOK
hs.encryptionBody = `{"algorithm":"m.megolm.v1.aes-sha2"}`
enc, err := store.IsEncrypted(context.Background(), roomID)
if err != nil {
t.Fatalf("IsEncrypted after recovery: %v", err)
}
if !enc {
t.Fatal("store did not retry after a transient failure")
}
}
// A burst of proactive sends into a cold room should resolve it once.
func TestIsEncryptedResolvesOncePerRoom(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusOK,
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
}
store := newTestStore(t, hs)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := store.IsEncrypted(context.Background(), roomID); err != nil {
t.Errorf("IsEncrypted: %v", err)
}
}()
}
wg.Wait()
if got := hs.encryptionHits.Load(); got != 1 {
t.Errorf("expected 1 state fetch for 20 concurrent sends, got %d", got)
}
}
// Encrypting to an empty member list would share the group session with nobody,
// so members must be resolved too.
func TestMembersResolveFromServer(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusOK,
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
membersBody: `{"chunk":[
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@alice:example.org","sender":"@alice:example.org","content":{"membership":"join"}},
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@bot:example.org","sender":"@bot:example.org","content":{"membership":"join"}}
]}`,
}
store := newTestStore(t, hs)
members, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID)
if err != nil {
t.Fatalf("GetRoomJoinedOrInvitedMembers: %v", err)
}
if len(members) != 2 {
t.Fatalf("expected 2 members, got %d (%v) — the group session would be shared to nobody", len(members), members)
}
if _, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID); err != nil {
t.Fatalf("second call: %v", err)
}
if got := hs.memberHits.Load(); got != 1 {
t.Errorf("expected members to be cached after one fetch, got %d", got)
}
}

View File

@@ -306,6 +306,18 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
// Bored adventurers (gogobee_boredom_plan.md §1). The idle clock for
// autonomous expedition starts. Deliberately NOT last_active_at: that
// one is auto-bumped by saveAdvCharacter, so the autopilot's own writes
// would refresh a bored character's idle clock and it would never
// qualify again. Written only by markPlayerAction, from a real player
// action against Adventure (any interface, not just Matrix chatter).
`ALTER TABLE player_meta ADD COLUMN last_player_action_at DATETIME`,
`ALTER TABLE player_meta ADD COLUMN last_boredom_at DATETIME`,
// Marks an expedition the adventurer started by itself. The idle reaper
// reads it: an expedition nobody asked for must not shield its player
// from the shame DM or hold their streak (gogobee_boredom_plan.md §6).
`ALTER TABLE dnd_expedition ADD COLUMN boredom INTEGER NOT NULL DEFAULT 0`,
// Adv 2.0 Phase G1 — branching zone graph run-state columns.
// current_node / visited_nodes / node_choices live alongside the
// legacy current_room / room_seq_json during the migration; the
@@ -1722,6 +1734,44 @@ CREATE TABLE IF NOT EXISTS community_pot (
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Mischief Makers: a paid monster hit on a player who is out on an expedition.
-- The buyer's euros are debited at placement (paid); fee is the payout basis the
-- target's survival purse is a percentage of — the sign surcharge is a pure sink
-- and never inflates the purse, which is what keeps a payout strictly below what
-- the buyer spent (collusion loses to !baltransfer, which is free).
--
-- Lifecycle: open → delivering → delivered (outcome survived|downed) | fizzled.
-- Every transition is a conditional UPDATE, so a double-fire of the ticker or a
-- restart mid-delivery can never pay twice. No bootstrap — absent row == no
-- contract in flight.
CREATE TABLE IF NOT EXISTS mischief_contracts (
contract_id TEXT PRIMARY KEY,
buyer_id TEXT NOT NULL,
target_id TEXT NOT NULL,
tier TEXT NOT NULL,
fee INTEGER NOT NULL,
paid INTEGER NOT NULL,
status TEXT NOT NULL,
signed INTEGER NOT NULL DEFAULT 0,
escalation_count INTEGER NOT NULL DEFAULT 0,
blessing_count INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'matrix',
outcome TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
window_ends_at DATETIME NOT NULL,
resolved_at DATETIME
);
-- One live contract per target, enforced by the database rather than by a
-- read-then-write check. The placement path holds only the BUYER's lock, so two
-- different buyers racing to hit the same person would both pass an in-code
-- "is one already live?" test. This partial unique index is what actually stops
-- the second insert; the loser is refunded.
CREATE UNIQUE INDEX IF NOT EXISTS idx_mischief_one_live_per_target
ON mischief_contracts(target_id) WHERE status IN ('open', 'delivering');
CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id, status);
CREATE INDEX IF NOT EXISTS idx_mischief_buyer ON mischief_contracts(buyer_id, created_at);
CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_at);
-- Babysitting Service
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -25,6 +25,25 @@ var ExpeditionStart = []string{
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
}
// ─────────────────────────────────────────────────────────────────────────────
// EXPEDITION START — BOREDOM (the adventurer left without you)
//
// Fired by the boredom ticker after a long silence. The player is not
// reading this live; it's a note left on the table. Deadpan, faintly
// reproachful, never cruel — and never pretending the gear got checked,
// because it didn't (gogobee_boredom_plan.md §5).
// ─────────────────────────────────────────────────────────────────────────────
var ExpeditionBoredomStart = []string{
"You didn't come. That's alright — it happens, and I'm not going to make it a thing. But the sword was getting heavy on the wall and I've packed what we had. Which was not much. Noted for the record, not as a complaint.",
"I waited. Then I waited past the point where waiting was the sensible option, and somewhere in there the waiting turned into leaving. We're going. Same kit as last time, because last time is when you last touched it.",
"Here's the situation: there's a dungeon, there's daylight, and there's nobody telling me not to. I've made a decision. I hope it was the one you'd have made, though I concede I have no way of checking.",
"Supplies: the cheapest available. Equipment: whatever was already on the rack. Plan: walk in, see what happens. I'm aware of how that sounds. I'm going anyway.",
"The gear hasn't moved since you left it. I checked. I checked twice, actually, in case the first check was wrong, and it wasn't. So we go as we are — which is to say, as we were.",
"Restlessness is not a stat I can show you on the sheet, but it accumulates, and it has. Off we go. Lightly provisioned and unimproved, but off.",
"I've done the arithmetic on standing still and it doesn't come out well. So: a dungeon, one supply pack, and the same armour that's been good enough up to now. 'Good enough' is doing a lot of work in that sentence.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MORNING BRIEFINGS — Generic
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -194,10 +194,55 @@ func (c *Client) drain(ctx context.Context) {
}
}
// RosterEntry is one adventurer's currently-true state for Pete's live board.
// Unlike a Fact, nothing here is an event — it is what is true right now.
type RosterEntry struct {
Token string `json:"token"` // stable per-player board token, never a Matrix handle
Name string `json:"name"` // character name only
Level int `json:"level"`
ClassRace string `json:"class_race,omitempty"`
Status string `json:"status"` // "expedition" | "idle"
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Day int `json:"day,omitempty"`
IdleHours int `json:"idle_hours,omitempty"`
}
// RosterSnapshot is the complete board. Complete is load-bearing: Pete replaces
// its whole board with this, so anyone omitted (opted out, no character) drops
// off the public page. A partial snapshot would silently strand people on it.
type RosterSnapshot struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []RosterEntry `json:"adventurers"`
}
// PushRoster sends the board to Pete, synchronously, and drops it on failure.
//
// Deliberately NOT on the durable queue that carries Facts. A fact is history —
// losing "Josie died" loses it forever, so it retries. A snapshot is a
// photograph of the present, and a retried one is a *lie*: by the time it lands,
// Josie has moved. The next tick carries the truth anyway, so a failed push is
// simply forgotten. That is also what lets Pete's staleness timer work — if we
// stay down, nothing arrives, and the board correctly stops claiming to be live.
func PushRoster(ctx context.Context, snap RosterSnapshot) error {
if !Enabled() {
return nil
}
payload, err := json.Marshal(snap)
if err != nil {
return err
}
return std.post(ctx, "/api/ingest/roster", payload)
}
// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the
// bearer-POST pattern in email_nag.go:sendCode.
func (c *Client) send(ctx context.Context, payload []byte) error {
url := c.cfg.IngestURL + "/api/ingest/adventure"
return c.post(ctx, "/api/ingest/adventure", payload)
}
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
url := c.cfg.IngestURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err

View File

@@ -170,6 +170,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
{Name: "mischief", Description: "Pay to send a monster after an adventurer who's out on an expedition", Usage: "!mischief send <tier> @user", Category: "Games"},
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
@@ -263,6 +264,11 @@ func (p *AdventurePlugin) Init() error {
// Revive any characters whose DeadUntil has expired
p.catchUpRespawns(chars)
// Seed the boredom idle clock before the ticker that reads it can wake:
// an unseeded column reads as "idle since character creation" for every
// pre-existing player.
bootstrapBoredomClock()
// Start schedulers — single shared stopCh allows graceful shutdown.
if p.stopCh == nil {
p.stopCh = make(chan struct{})
@@ -280,7 +286,10 @@ func (p *AdventurePlugin) Init() error {
go p.expeditionRecapTicker()
go p.expeditionAmbientTicker()
go p.expeditionAutoRunTicker()
go p.peteRosterTicker()
go p.expeditionExtractionSweepTicker()
go p.expeditionBoredomTicker()
go p.mischiefTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
@@ -321,6 +330,15 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
// expedition.
p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC())
// Bored adventurers (gogobee_boredom_plan.md §1) — the idle clock that
// decides when a character gives up waiting and leaves on its own. Stamped
// only for a real action against Adventure: idle chatter in a room is not
// tending your adventurer, and no ticker or autopilot path may touch this,
// or a bored character would refresh its own clock and never qualify again.
if p.isPlayerAdventureAction(ctx) {
markPlayerAction(ctx.Sender)
}
// 0. D&D layer commands (Phase 1 — work in rooms and DMs)
if p.IsCommand(ctx.Body, "setup") {
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))
@@ -346,6 +364,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "duel") {
return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
}
if p.IsCommand(ctx.Body, "mischief") {
return p.handleMischiefCmd(ctx, p.GetArgs(ctx.Body, "mischief"))
}
if p.IsCommand(ctx.Body, "arm") {
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
}
@@ -527,6 +548,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleRivalsCmd(ctx)
case lower == "duel" || strings.HasPrefix(lower, "duel "):
return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
case lower == "mischief" || strings.HasPrefix(lower, "mischief "):
return p.handleMischiefCmd(ctx, strings.TrimSpace(args[len("mischief"):]))
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
case lower == "blacksmith" || lower == "repair":

View File

@@ -0,0 +1,773 @@
package plugin
// Mischief Makers (M1) — paid monster hits on live expeditions.
//
// A buyer spends euros to send a monster at an adventurer who is out in a
// dungeon right now. The games room hears the contract is out; a window later
// the monster is delivered mid-run. Surviving it pays the target and unseals
// the buyer in Pete's bulletin. See gogobee_mischief_plan.md.
//
// Three properties the design leans on, all of them enforced here rather than
// trusted:
//
// - The monster is priced against the TARGET, not the dungeon they happen to
// be standing in. It comes from the zone pool of the target's own level
// bracket (mischiefBracketZones), which is the corpus the difficulty pass is
// calibrated on. The arena ladder is deliberately NOT the source: its
// BaseLethality is a legacy flat death chance and arena T5 one-shots a level
// 20, which would collapse every tier into an execution.
//
// - The payout basis is the BASE fee, never what the buyer actually paid. The
// +25% sign surcharge is a pure sink. With the per-tier percentages below
// capped at 75%, a survival purse is always strictly less than the buyer's
// outlay — so two players colluding to move money this way lose to
// `!baltransfer`, which is free. That cap is the whole anti-collusion story;
// no danger multiplier is needed.
//
// - Mischief maims, it does not murder. The delivery close-out floors every
// seat at 1 HP and force-extracts the expedition. A purchased attack that
// lands while the victim is asleep must not be able to delete their
// character — see mischiefResolveDowned.
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"math"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"github.com/google/uuid"
"maunium.net/go/mautrix/id"
)
const (
// mischiefWindow — how long the games room has to react before the monster
// is delivered. Long enough for a bless/escalate scramble (M2), short
// enough that a contract resolves inside one sitting.
mischiefWindow = 60 * time.Minute
// mischiefMinLevel — a level 1-2 adventurer is nobody's idea of a fair
// target, and the bracket-1 pool is the only thing we could send anyway.
mischiefMinLevel = 3
// mischiefTargetCooldown — a target gets a breather after one resolves, so
// a buyer with money can't chain-maim someone out of the game.
mischiefTargetCooldown = 24 * time.Hour
// mischiefBuyerDailyCap — contracts one buyer may place per rolling day.
mischiefBuyerDailyCap = 2
// mischiefBossPerTargetWindow — boss tier is "end this expedition" against a
// caster (0-14% survival in the M0 sweep). One per target per week, on top of
// the cooldown and the buyer cap.
mischiefBossPerTargetWindow = 7 * 24 * time.Hour
// mischiefSignSurcharge — the buyer pays this much on top to put their name
// on the contract from the start. Pure sink; see the payout-basis note above.
mischiefSignSurcharge = 0.25
// Fizzle: the expedition ended before the monster could find them.
mischiefFizzleRefund = 0.90
)
// Contract statuses. `delivering` is the claimed-but-unresolved state a CAS
// moves through, so a ticker double-fire finds nothing left to claim.
const (
mischiefStatusOpen = "open"
mischiefStatusDelivering = "delivering"
mischiefStatusDelivered = "delivered"
mischiefStatusFizzled = "fizzled"
)
const (
mischiefOutcomeSurvived = "survived"
mischiefOutcomeDowned = "downed"
)
// ── Tiers ────────────────────────────────────────────────────────────────────
// mischiefTier is one rung of the grunt→boss ladder. Fee and PayoutPct come
// from the M0 pricing sweep (n=2000/cell against the real combat engine) read
// against the live prod economy — see the tier table in the plan doc.
type mischiefTier struct {
Key string
Display string
Fee int // base fee; also the payout basis
PayoutPct float64 // survival purse as a fraction of Fee. Capped at 0.75.
Blurb string
}
var mischiefTiers = []mischiefTier{
{Key: "grunt", Display: "Grunt", Fee: 40, PayoutPct: 0.40,
Blurb: "One of the local nasties. Mostly theatre — it'll cost them blood, not the run."},
{Key: "mob", Display: "Mob", Fee: 100, PayoutPct: 0.50,
Blurb: "Three of them, back to back. Attrition, and a real dent in the supplies."},
{Key: "elite", Display: "Elite", Fee: 350, PayoutPct: 0.65,
Blurb: "Something that hunts on purpose, and it opens with a free swing. A coin-flip at their level."},
{Key: "boss", Display: "Boss", Fee: 1200, PayoutPct: 0.75,
Blurb: "The worst thing in their bracket, with the drop on them. This ends expeditions."},
}
func mischiefTierByKey(key string) (mischiefTier, bool) {
for _, t := range mischiefTiers {
if t.Key == strings.ToLower(strings.TrimSpace(key)) {
return t, true
}
}
return mischiefTier{}, false
}
// mischiefSignedFee is what a buyer pays to put their name on the contract up
// front. The extra is a sink — mischiefPurse never sees it.
func mischiefSignedFee(fee int) int {
return int(math.Round(float64(fee) * (1 + mischiefSignSurcharge)))
}
// mischiefPurse is the target's cut for surviving: a percentage of the BASE fee
// (which escalations raise, and the sign surcharge does not). Hard-capped at 75%
// so no combination of tier + escalation can pay out more than three-quarters of
// what was spent.
func mischiefPurse(fee int, pct float64) int {
if pct > 0.75 {
pct = 0.75
}
return int(math.Round(float64(fee) * pct))
}
// ── Monster selection ────────────────────────────────────────────────────────
// mischiefBracketZones — the zone whose enemy pool a target of each level
// bracket draws its attacker from. The target's *current* dungeon is not used:
// a level 20 slumming in a tier 1 zone should still be sent something that can
// find them, and a level 4 who wandered into a tier 4 zone should not be sent
// its boss.
var mischiefBracketZones = map[int]ZoneID{
1: ZoneGoblinWarrens,
2: ZoneSunkenTemple,
3: ZoneManorBlackspire,
4: ZoneUnderdark,
5: ZoneDragonsLair,
}
func mischiefBracketForLevel(level int) int {
switch {
case level <= 3:
return 1
case level <= 7:
return 2
case level <= 12:
return 3
case level <= 17:
return 4
}
return 5
}
// mischiefBracketZone resolves the zone a target's attacker is drawn from.
func mischiefBracketZone(level int) ZoneDefinition {
zone, _ := getZone(mischiefBracketZones[mischiefBracketForLevel(level)])
return zone
}
func mischiefPickEnemy(zone ZoneDefinition, elite bool, rng *rand.Rand) DnDMonsterTemplate {
var pool []ZoneEnemy
for _, e := range zone.Enemies {
if e.IsElite == elite {
pool = append(pool, e)
}
}
if len(pool) == 0 {
pool = zone.Enemies
}
if len(pool) == 0 {
return DnDMonsterTemplate{}
}
return dndBestiary[pool[rng.IntN(len(pool))].BestiaryID]
}
// mischiefMonsters returns the fight chain a tier sends, and whether the first
// monster gets the ambush (the attacker's privilege — it was lying in wait).
// This IS the shape the M0 sweep priced; changing it invalidates the fee table.
//
// grunt = one non-elite zone enemy, no ambush
// mob = three non-elite zone enemies back to back (HP carries), no ambush
// elite = one elite zone enemy, ambush
// boss = the zone boss, ambush
func mischiefMonsters(tierKey string, zone ZoneDefinition, rng *rand.Rand) (chain []DnDMonsterTemplate, ambush bool) {
switch tierKey {
case "grunt":
return []DnDMonsterTemplate{mischiefPickEnemy(zone, false, rng)}, false
case "mob":
return []DnDMonsterTemplate{
mischiefPickEnemy(zone, false, rng),
mischiefPickEnemy(zone, false, rng),
mischiefPickEnemy(zone, false, rng),
}, false
case "elite":
return []DnDMonsterTemplate{mischiefPickEnemy(zone, true, rng)}, true
case "boss":
return []DnDMonsterTemplate{dndBestiary[zone.Boss.BestiaryID]}, true
}
return nil, false
}
// ── Model + persistence ──────────────────────────────────────────────────────
type mischiefContract struct {
ID string
BuyerID id.UserID
TargetID id.UserID
Tier string
Fee int // payout basis (base fee + escalations)
Paid int // what the buyer actually parted with
Status string
Signed bool
EscalationCount int
BlessingCount int
Source string
Outcome string
CreatedAt time.Time
WindowEndsAt time.Time
}
const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed,
escalation_count, blessing_count, source, COALESCE(outcome, ''), created_at, window_ends_at`
func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error) {
c := &mischiefContract{}
err := row.Scan(&c.ID, &c.BuyerID, &c.TargetID, &c.Tier, &c.Fee, &c.Paid, &c.Status,
&c.Signed, &c.EscalationCount, &c.BlessingCount, &c.Source, &c.Outcome,
&c.CreatedAt, &c.WindowEndsAt)
if err != nil {
return nil, err
}
return c, nil
}
// insertMischiefContract writes the contract, and FAILS if one is already live
// against this target (idx_mischief_one_live_per_target). That error is the
// serialization point for two buyers racing at the same victim — the caller must
// refund on it, not ignore it.
//
// Every timestamp is bound as a Go time.Time rather than left to
// CURRENT_TIMESTAMP: created_at and window_ends_at are both compared against
// bound Go times later (the daily cap, the due sweep), and mixing SQLite's own
// text stamp with the driver's encoding is how you get a comparison that is
// lexicographic-by-accident.
func insertMischiefContract(c *mischiefContract) error {
_, err := db.Get().Exec(
`INSERT INTO mischief_contracts
(contract_id, buyer_id, target_id, tier, fee, paid, status, signed, source,
created_at, window_ends_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, string(c.BuyerID), string(c.TargetID), c.Tier, c.Fee, c.Paid,
c.Status, c.Signed, c.Source, c.CreatedAt, c.WindowEndsAt)
return err
}
// claimMischiefForDelivery moves open → delivering and reports whether THIS
// caller won the race. Exactly one delivery per contract, whatever the ticker
// does across a restart.
func claimMischiefForDelivery(contractID string) bool {
res := db.ExecResult("mischief: claim delivery",
`UPDATE mischief_contracts SET status = ?
WHERE contract_id = ? AND status = ?`,
mischiefStatusDelivering, contractID, mischiefStatusOpen)
if res == nil {
return false
}
n, _ := res.RowsAffected()
return n == 1
}
// resolveMischiefContract stamps the terminal state. Called only by the
// delivery path, which already holds the claim.
func resolveMischiefContract(contractID, status, outcome string) {
db.Exec("mischief: resolve contract",
`UPDATE mischief_contracts
SET status = ?, outcome = ?, resolved_at = ?
WHERE contract_id = ?`,
status, outcome, time.Now().UTC(), contractID)
}
// fizzleMischiefContract claims an open contract straight to fizzled — the
// target's expedition ended before the monster found them. Same CAS discipline
// as delivery, so a fizzle and a delivery can never both fire.
func fizzleMischiefContract(contractID string) bool {
return casMischiefStatus("mischief: fizzle contract", contractID,
mischiefStatusOpen, mischiefStatusFizzled, mischiefStatusFizzled)
}
// abandonStaleMischief releases a stuck delivery. CAS off `delivering`, so a
// delivery that is merely slow (a long fight) cannot be swept out from under
// itself and double-refunded.
func abandonStaleMischief(contractID string) bool {
return casMischiefStatus("mischief: abandon stale delivery", contractID,
mischiefStatusDelivering, mischiefStatusFizzled, mischiefStatusFizzled)
}
// casMischiefStatus is the one status transition every terminal path takes: move
// from → to only if the row is still in `from`, and report whether THIS caller
// won. Every transition is conditional, which is what makes a ticker double-fire
// or a restart mid-delivery unable to pay (or refund) twice.
func casMischiefStatus(label, contractID, from, to, outcome string) bool {
res := db.ExecResult(label,
`UPDATE mischief_contracts
SET status = ?, outcome = ?, resolved_at = ?
WHERE contract_id = ? AND status = ?`,
to, outcome, time.Now().UTC(), contractID, from)
if res == nil {
return false
}
n, _ := res.RowsAffected()
return n == 1
}
// loadStaleMischiefDeliveries returns contracts stuck in `delivering` — claimed
// by a delivery that then crashed, or was killed mid-fight by a restart. Without
// this sweep the row lives forever: the target can never be targeted again (the
// live-contract check counts `delivering`) and the buyer's money is simply gone.
func loadStaleMischiefDeliveries(before time.Time) []mischiefContract {
return loadMischiefByStatus(mischiefStatusDelivering, before)
}
// loadMischiefDue returns open contracts whose window has closed.
func loadMischiefDue(now time.Time) []mischiefContract {
return loadMischiefByStatus(mischiefStatusOpen, now)
}
func loadMischiefByStatus(status string, dueBy time.Time) []mischiefContract {
rows, err := db.Get().Query(
`SELECT `+mischiefCols+` FROM mischief_contracts
WHERE status = ? AND window_ends_at <= ?`,
status, dueBy)
if err != nil {
return nil
}
defer rows.Close()
var out []mischiefContract
for rows.Next() {
if c, err := scanMischief(rows); err == nil {
out = append(out, *c)
}
}
return out
}
// liveMischiefForTarget returns the contract already in flight against a target,
// or nil. One at a time: two monsters converging on the same person turns a
// mischief into a mugging.
func liveMischiefForTarget(targetID id.UserID) *mischiefContract {
row := db.Get().QueryRow(
`SELECT `+mischiefCols+` FROM mischief_contracts
WHERE target_id = ? AND status IN (?, ?)
ORDER BY created_at DESC LIMIT 1`,
string(targetID), mischiefStatusOpen, mischiefStatusDelivering)
c, err := scanMischief(row)
if err != nil {
return nil
}
return c
}
// mischiefTargetCoolingDown reports whether the target survived (or was floored
// by) a delivered contract too recently to be sent another.
//
// Only a DELIVERED contract cools a target down. A fizzle never reached them,
// and a crash-swept stranding never happened at all — counting either would sell
// cheap immunity: a target whose friend places a grunt and who then extracts pays
// 10% of a grunt fee for a full day of being untargetable.
func mischiefTargetCoolingDown(targetID id.UserID, now time.Time) (bool, time.Duration) {
var resolved time.Time
err := db.Get().QueryRow(
`SELECT resolved_at FROM mischief_contracts
WHERE target_id = ? AND status = ? AND resolved_at IS NOT NULL
ORDER BY resolved_at DESC LIMIT 1`,
string(targetID), mischiefStatusDelivered).Scan(&resolved)
if err != nil {
return false, 0 // no row (or unreadable stamp) == not cooling down
}
if wait := mischiefTargetCooldown - now.Sub(resolved.UTC()); wait > 0 {
return true, wait
}
return false, 0
}
// mischiefBuyerCountSince is how many contracts this buyer has placed in the
// window — the anti-spam cap. Fizzled ones count: the limit is on how often you
// may point a monster at someone, not on how often it connects.
func mischiefBuyerCountSince(buyerID id.UserID, since time.Time) int {
var n int
_ = db.Get().QueryRow(
`SELECT COUNT(*) FROM mischief_contracts WHERE buyer_id = ? AND created_at > ?`,
string(buyerID), since).Scan(&n)
return n
}
// mischiefBossCountSince counts boss-tier contracts aimed at a target inside the
// window, from anyone. Boss is the "end their expedition" button; one a week.
//
// Fizzled ones do NOT count, and that asymmetry with the buyer cap is deliberate:
// the buyer cap limits how often you may POINT a monster at someone, but this cap
// protects the target from being boss'd, and a boss that never found them is not
// something they need protecting from. Counting it would let a friendly buyer burn
// the target's weekly boss slot for the price of a fizzle rake.
func mischiefBossCountSince(targetID id.UserID, since time.Time) int {
var n int
_ = db.Get().QueryRow(
`SELECT COUNT(*) FROM mischief_contracts
WHERE target_id = ? AND tier = 'boss' AND created_at > ? AND status != ?`,
string(targetID), since, mischiefStatusFizzled).Scan(&n)
return n
}
// ── Eligibility ──────────────────────────────────────────────────────────────
// mischiefTargetable reports why a target can't be sent a monster, or "" if they
// can. The active expedition is the whole premise: the monster has to have
// somewhere to walk in from.
func (p *AdventurePlugin) mischiefTargetable(targetID id.UserID, tier mischiefTier, now time.Time) string {
dndChar, err := LoadDnDCharacter(targetID)
if err != nil || dndChar == nil {
return fmt.Sprintf("%s has no adventurer.", p.DisplayName(targetID))
}
// A dead character can still own an 'active' expedition row — that drift is
// exactly what the ambient ticker keeps a run-liveness net for. Don't sell a
// hit on a corpse.
if advChar, err := loadAdvCharacter(targetID); err != nil || advChar == nil || !advChar.Alive {
return fmt.Sprintf("%s is in no state to be attacked. Leave them be.", p.DisplayName(targetID))
}
if dndChar.Level < mischiefMinLevel {
return fmt.Sprintf("%s is only level %d — nobody's putting a price on a rookie's head (level %d+).",
p.DisplayName(targetID), dndChar.Level, mischiefMinLevel)
}
exp, err := getActiveExpedition(targetID)
if err != nil || exp == nil {
return fmt.Sprintf("%s isn't out on an expedition. Monsters don't do house calls.",
p.DisplayName(targetID))
}
if c := liveMischiefForTarget(targetID); c != nil {
return fmt.Sprintf("Somebody already has coin on %s. Wait for it to land.", p.DisplayName(targetID))
}
if cooling, wait := mischiefTargetCoolingDown(targetID, now); cooling {
return fmt.Sprintf("%s has only just shaken one off. Try again %s.",
p.DisplayName(targetID), formatDuration(wait))
}
if tier.Key == "boss" && mischiefBossCountSince(targetID, now.Add(-mischiefBossPerTargetWindow)) > 0 {
return fmt.Sprintf("%s has already had a boss sent after them this week. Once is plenty.",
p.DisplayName(targetID))
}
return ""
}
// ── Command surface ──────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleMischiefCmd(ctx MessageContext, args string) error {
fields := strings.Fields(strings.TrimSpace(args))
if len(fields) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, p.mischiefBoard())
}
switch strings.ToLower(fields[0]) {
case "board", "tiers", "help":
return p.SendReply(ctx.RoomID, ctx.EventID, p.mischiefBoard())
case "status":
return p.mischiefStatusCmd(ctx)
case "send":
return p.mischiefSendCmd(ctx, fields[1:])
default:
return p.SendReply(ctx.RoomID, ctx.EventID,
"Unknown option. `!mischief` for the board · `!mischief send <tier> @user` · `!mischief status`")
}
}
func (p *AdventurePlugin) mischiefBoard() string {
var b strings.Builder
b.WriteString("😈 **Mischief Makers** — put coin on an adventurer's head while they're out in a dungeon.\n")
b.WriteString("The monster comes from their own bracket, so it's always a fight worth watching. ")
b.WriteString("If they survive it, they keep a cut of your money — and the town finds out it was you.\n\n")
for _, t := range mischiefTiers {
b.WriteString(fmt.Sprintf("· **%s** — %s (signed: %s) — survivor keeps %s\n _%s_\n",
t.Display, fmtEuro(t.Fee), fmtEuro(mischiefSignedFee(t.Fee)),
fmtEuro(mischiefPurse(t.Fee, t.PayoutPct)), t.Blurb))
}
b.WriteString("\n`!mischief send <tier> @user` — anonymous · add `signed` to put your name on it up front.\n")
b.WriteString(fmt.Sprintf("_They have to be out on an expedition, level %d+. It lands about %s later._",
mischiefMinLevel, formatDuration(mischiefWindow)))
return b.String()
}
func (p *AdventurePlugin) mischiefStatusCmd(ctx MessageContext) error {
if c := liveMischiefForTarget(ctx.Sender); c != nil {
t, _ := mischiefTierByKey(c.Tier)
who := "Someone"
if c.Signed {
who = "**" + p.DisplayName(c.BuyerID) + "**"
}
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.",
who, strings.ToLower(t.Display), formatDuration(time.Until(c.WindowEndsAt)),
fmtEuro(mischiefPurse(c.Fee, t.PayoutPct))))
}
rows, err := db.Get().Query(
`SELECT `+mischiefCols+` FROM mischief_contracts
WHERE buyer_id = ? AND status IN (?, ?) ORDER BY created_at DESC`,
string(ctx.Sender), mischiefStatusOpen, mischiefStatusDelivering)
if err == nil {
defer rows.Close()
var lines []string
for rows.Next() {
if c, err := scanMischief(rows); err == nil {
lines = append(lines, fmt.Sprintf("· a **%s** for **%s**, landing %s",
c.Tier, p.DisplayName(c.TargetID), formatDuration(time.Until(c.WindowEndsAt))))
}
}
if len(lines) > 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
"😈 **Your contracts in flight**\n"+strings.Join(lines, "\n"))
}
}
return p.SendReply(ctx.RoomID, ctx.EventID,
"Nothing out on you, and nothing out from you. `!mischief` for the board.")
}
func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) error {
if len(fields) < 2 {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!mischief send <grunt|mob|elite|boss> @user [signed]`")
}
tier, ok := mischiefTierByKey(fields[0])
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID,
"That's not a tier. Try `grunt`, `mob`, `elite` or `boss` — `!mischief` for the board.")
}
targetID, ok := p.ResolveUser(fields[1], ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Could not resolve that user. Usage: `!mischief send <tier> @user`")
}
if targetID == ctx.Sender {
return p.SendReply(ctx.RoomID, ctx.EventID,
"You can't put a price on your own head. Get help elsewhere.")
}
signed := len(fields) > 2 && strings.EqualFold(fields[2], "signed")
// Serialise this buyer's placement so a double-submit can't pass the daily
// cap twice and double-debit — same discipline as the duel escrow.
lock := p.advUserLock(ctx.Sender)
lock.Lock()
defer lock.Unlock()
now := time.Now().UTC()
if n := mischiefBuyerCountSince(ctx.Sender, now.Add(-24*time.Hour)); n >= mischiefBuyerDailyCap {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"You've already sent %d monsters today. Even mischief keeps office hours.", n))
}
if reason := p.mischiefTargetable(targetID, tier, now); reason != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, reason)
}
price := tier.Fee
if signed {
price = mischiefSignedFee(tier.Fee)
}
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < price {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"A %s runs %s — you have %s.", strings.ToLower(tier.Display), fmtEuro(price), fmtEuro(int(bal))))
}
if !p.euro.Debit(ctx.Sender, float64(price), "mischief_contract") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.")
}
c := &mischiefContract{
ID: uuid.NewString(),
BuyerID: ctx.Sender,
TargetID: targetID,
Tier: tier.Key,
Fee: tier.Fee,
Paid: price,
Status: mischiefStatusOpen,
Signed: signed,
Source: "matrix",
CreatedAt: now,
WindowEndsAt: now.Add(mischiefWindow),
}
// The one-live-contract-per-target index is the real guard: the check above
// ran under this buyer's lock, and another buyer could have slipped a contract
// onto the same target since. Losing that race costs the buyer nothing.
if err := insertMischiefContract(c); err != nil {
p.euro.Credit(ctx.Sender, float64(price), "mischief_refund_contested")
slog.Warn("mischief: contract insert rejected",
"buyer", ctx.Sender, "target", targetID, "err", err)
// Refund either way, but don't tell a buyer a rival outbid them when the
// write simply failed — they'd stop retrying and believe in a contract that
// does not exist. The index is the only thing that legitimately rejects here.
if liveMischiefForTarget(targetID) != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"Somebody beat you to **%s** — there's already a monster on the way. You've been refunded.",
p.DisplayName(targetID)))
}
return p.SendReply(ctx.RoomID, ctx.EventID,
"The contract didn't take — something went wrong on our end. You've been refunded; try again.")
}
p.announceMischiefContract(c, tier)
emitMischiefContractNews(c, tier)
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"😈 Done. A **%s** is on its way to **%s** — it finds them in about %s.\n%s",
strings.ToLower(tier.Display), p.DisplayName(targetID), formatDuration(mischiefWindow),
mischiefBuyerNote(signed)))
}
func mischiefBuyerNote(signed bool) string {
if signed {
return "_Your name is on it. Everyone already knows._"
}
return "_Nobody knows it was you — unless they walk away from it._"
}
// ── Announcements ────────────────────────────────────────────────────────────
// announceMischiefContract tells the games room a hit is out. The buyer is named
// only if they paid to sign it; otherwise this is the whole point of the window —
// the room knows someone did it and doesn't know who.
func (p *AdventurePlugin) announceMischiefContract(c *mischiefContract, tier mischiefTier) {
gr := gamesRoom()
if gr == "" {
return
}
who := "**Someone in town**"
if c.Signed {
who = fmt.Sprintf("**%s**", p.DisplayName(c.BuyerID))
}
p.SendMessage(gr, fmt.Sprintf(
"😈 %s has put %s on **%s**'s head — a **%s** is already looking for them out there.\n"+
"It finds them in about %s. If they walk away from it, they keep %s of that money — and we all find out who paid.",
who, fmtEuro(c.Paid), p.DisplayName(c.TargetID), strings.ToLower(tier.Display),
formatDuration(time.Until(c.WindowEndsAt)), fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct))))
}
func (p *AdventurePlugin) announceMischiefSurvived(c *mischiefContract, monsterName string, purse int) {
gr := gamesRoom()
if gr == "" {
return
}
// The unseal. Anonymity is the buyer's to lose, and losing it is what makes
// a survival worth announcing.
p.SendMessage(gr, fmt.Sprintf(
"🛡️ **%s walked away from it.** The **%s** that came for them is dead, and %s is %s richer for the trouble.\n"+
"The coin came from **%s**.",
p.DisplayName(c.TargetID), monsterName, p.DisplayName(c.TargetID), fmtEuro(purse),
p.DisplayName(c.BuyerID)))
}
func (p *AdventurePlugin) announceMischiefDowned(c *mischiefContract, monsterName string) {
gr := gamesRoom()
if gr == "" {
return
}
who := "Whoever paid for it"
if c.Signed {
who = fmt.Sprintf("**%s**, who paid for it,", p.DisplayName(c.BuyerID))
}
p.SendMessage(gr, fmt.Sprintf(
"💀 **%s didn't walk away.** A **%s** found them mid-run and put them on the floor. "+
"They're being carried home — expedition over, pockets lighter.\n%s keeps nothing: the money's gone to the community pot.",
p.DisplayName(c.TargetID), monsterName, who))
}
// ── Pete news ────────────────────────────────────────────────────────────────
//
// Deploy Pete BEFORE gogobee whenever these types change: Pete 400s an unknown
// event_type, gogobee retries, and the bulletin parks forever.
func emitMischiefContractNews(c *mischiefContract, tier mischiefTier) {
target := charName(c.TargetID)
if target == "" {
return
}
buyer := ""
buyerUser := id.UserID("")
if c.Signed {
buyer = charName(c.BuyerID)
buyerUser = c.BuyerID
}
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("mischief_contract:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
EventType: "mischief_contract",
Tier: "priority",
Subject: target,
Opponent: buyer,
Boss: tier.Display,
Stakes: fmtEuro(c.Paid),
Level: charLevel(c.TargetID),
OccurredAt: ts,
}, c.TargetID, buyerUser)
}
// emitMischiefResolvedNews files the survival or the maiming. On a survival the
// buyer is named whether or not they signed — that is the unseal, and it is the
// only brake on casual griefing. (`!news optout` still anonymizes them to "an
// adventurer", as it does everywhere else.)
func emitMischiefResolvedNews(c *mischiefContract, monsterName, outcome string, purse int) {
target := charName(c.TargetID)
if target == "" {
return
}
eventType := "mischief_downed"
buyer, buyerUser := "", id.UserID("")
if outcome == mischiefOutcomeSurvived {
eventType = "mischief_survived"
buyer, buyerUser = charName(c.BuyerID), c.BuyerID
} else if c.Signed {
buyer, buyerUser = charName(c.BuyerID), c.BuyerID
}
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("%s:%s:%d", eventType, eventToken(c.TargetID, "mischief:"+c.ID), ts),
EventType: eventType,
Tier: "priority",
Subject: target,
Opponent: buyer,
Boss: monsterName,
Stakes: fmtEuro(purse),
Level: charLevel(c.TargetID),
Outcome: outcome,
OccurredAt: ts,
}, c.TargetID, buyerUser)
}
func emitMischiefFizzledNews(c *mischiefContract, refund int) {
target := charName(c.TargetID)
if target == "" {
return
}
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("mischief_fizzled:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
EventType: "mischief_fizzled",
Tier: "bulletin",
Subject: target,
Stakes: fmtEuro(refund),
Outcome: "fizzled",
OccurredAt: ts,
}, c.TargetID, "")
}
// mischiefContractByID is the test/ops read path.
func mischiefContractByID(contractID string) (*mischiefContract, error) {
row := db.Get().QueryRow(
`SELECT `+mischiefCols+` FROM mischief_contracts WHERE contract_id = ?`, contractID)
c, err := scanMischief(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return c, err
}

View File

@@ -0,0 +1,445 @@
package plugin
// Mischief Makers (M1) — delivery.
//
// The sibling of the ambient ticker: once a contract's window closes, the
// monster has to actually find the target mid-run. runMischiefInterrupt is
// modelled line-for-line on runHarvestInterrupt (pick enemy → ambush nick →
// runZoneCombatRoster → close out), with two deliberate departures:
//
// - It never calls recordZoneKill and never advances zone state. The fight is
// extrinsic to the dungeon: somebody bought it, the dungeon didn't send it.
// Crediting it as a zone kill would let a buyer unlock a target's
// RequiresKill resource gates for them.
//
// - It never marks anybody dead. runHarvestInterrupt's loss path perma-kills;
// a *purchased* attack that lands while its victim is asleep must not be
// able to delete their character. Mischief maims: floor at 1 HP,
// force-extract, take a bite out of the un-banked coins.
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// mischiefTickInterval — how often we look for contracts whose window has
// closed. The window itself is the pacing; the tick just has to be fine-grained
// enough that "about an hour" isn't a lie.
const mischiefTickInterval = time.Minute
// mischiefDeliveryGrace — how long past its window a contract may sit in
// `delivering` before the sweep calls it stranded. Comfortably longer than any
// chain of auto-resolved fights takes, so a slow delivery is never swept out
// from under itself (and the CAS in abandonStaleMischief is the backstop if it
// somehow is).
const mischiefDeliveryGrace = 15 * time.Minute
// mischiefTreasureWeight / mischiefRenownXP — the survival extras. Deliberately
// modest: the purse is the reward, this is the garnish.
const (
mischiefTreasureWeight = 1.0
mischiefEliteRenownXP = 40
mischiefBossRenownXP = 120
mischiefBossCacheSize = 2
)
func (p *AdventurePlugin) mischiefTicker() {
ticker := time.NewTicker(mischiefTickInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.fireMischiefDeliveries(time.Now().UTC())
}
}
}
// fireMischiefDeliveries walks contracts whose window has closed and either
// delivers the monster or fizzles the contract.
//
// The gates mirror the ambient ticker's, for the same reason: a mid-run event
// that talks over a live turn-based fight, or lands on top of the 06:00
// briefing, reads as a bug even when it isn't. A blocked contract is NOT
// fizzled — it simply waits for the next tick. The only thing that fizzles a
// contract is the expedition ending, which is what the buyer was actually
// betting against.
func (p *AdventurePlugin) fireMischiefDeliveries(now time.Time) {
if inAmbientQuietWindow(now) {
return
}
p.sweepStaleMischief(now)
for _, c := range loadMischiefDue(now) {
contract := c
p.fireOneMischiefDelivery(&contract)
}
}
// fireOneMischiefDelivery resolves a single due contract under the TARGET's lock.
//
// The lock is not optional. hasActiveCombatSession only sees the turn-based
// engine; the target's own `!explore` / autopilot walk resolves its fights
// inline (runHarvestInterrupt → runZoneCombatRoster) under advUserLock and
// reports no session. Without taking that same lock, a delivery can run a second
// combat against the same character sheet concurrently — two LoadDnDCharacter →
// mutate → SaveDnDCharacter writers, last write wins, and a whole fight's HP cost
// vanishes. The boredom ticker takes this lock for exactly this reason.
//
// Everything below the lock runs on the auto-resolve path, which takes no user
// locks of its own, so there is nothing here to deadlock against.
func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) {
target := contract.TargetID
lock := p.advUserLock(target)
lock.Lock()
defer lock.Unlock()
// Re-read under the lock: whatever the due-sweep saw, the expedition may have
// ended while we were queuing behind the target's own command.
exp, err := getActiveExpedition(target)
if err != nil || exp == nil || exp.Status != ExpeditionStatusActive {
p.fizzleMischief(contract)
return
}
// Same safety net the ambient ticker keeps: the expedition row can still
// say 'active' after the player has functionally left the dungeon.
if exp.RunID != "" {
if run, _ := getZoneRun(exp.RunID); run == nil || !run.IsActive() {
p.fizzleMischief(contract)
return
}
}
if hasActiveCombatSession(target) {
return // they're mid-fight; the monster can wait a minute
}
if !claimMischiefForDelivery(contract.ID) {
return // another tick (or another process) already has it
}
if err := p.deliverMischief(contract, exp); err != nil {
slog.Error("mischief: delivery failed",
"contract", contract.ID, "target", target, "err", err)
}
}
// sweepStaleMischief releases contracts stranded in `delivering` — the process
// died between the claim and the close-out (a restart mid-fight, a panic). The
// row would otherwise be immortal: the target can never be targeted again, and
// the buyer's money never comes back.
//
// The buyer is made whole in full rather than rake-charged. A stranded delivery
// is our crash, not a bet they lost.
func (p *AdventurePlugin) sweepStaleMischief(now time.Time) {
for _, c := range loadStaleMischiefDeliveries(now.Add(-mischiefDeliveryGrace)) {
contract := c
if !abandonStaleMischief(contract.ID) {
continue
}
p.euro.Credit(contract.BuyerID, float64(contract.Paid), "mischief_refund_stranded")
p.SendDM(contract.BuyerID, fmt.Sprintf(
"😾 Your **%s** never reached **%s** — something went wrong on our end. Fully refunded: %s.",
contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(contract.Paid)))
slog.Warn("mischief: swept stranded delivery",
"contract", contract.ID, "target", contract.TargetID)
}
}
// fizzleMischief refunds the buyer (minus the rake) when the monster arrives to
// an empty dungeon. The CAS is what makes the refund safe: a contract that was
// simultaneously claimed for delivery cannot also be refunded here.
func (p *AdventurePlugin) fizzleMischief(c *mischiefContract) {
if !fizzleMischiefContract(c.ID) {
return
}
refund := int(float64(c.Paid) * mischiefFizzleRefund)
rake := c.Paid - refund
if refund > 0 {
p.euro.Credit(c.BuyerID, float64(refund), "mischief_fizzle_refund")
}
if rake > 0 {
communityPotAdd(rake)
trackTaxPaid(c.BuyerID, rake)
}
p.SendDM(c.BuyerID, fmt.Sprintf(
"😾 Your **%s** got to the dungeon and found nobody home — **%s** was already out of there.\n"+
"You're refunded %s. The other %s stays with the town, for its trouble.",
c.Tier, p.DisplayName(c.TargetID), fmtEuro(refund), fmtEuro(rake)))
emitMischiefFizzledNews(c, refund)
}
// deliverMischief runs the purchased fight and closes it out. By the time this
// is called the contract is claimed, so it resolves exactly once.
func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition) error {
tier, ok := mischiefTierByKey(c.Tier)
if !ok {
return fmt.Errorf("unknown mischief tier %q", c.Tier)
}
target := c.TargetID
dndChar, err := LoadDnDCharacter(target)
if err != nil || dndChar == nil {
// The target evaporated between claim and delivery. Nothing to attack —
// treat it as a fizzle so the buyer isn't charged for a no-show.
p.refundClaimedMischief(c, "target has no character")
return nil
}
// The attacker comes from the target's own bracket, not the dungeon they are
// standing in. See mischiefBracketZones.
bracket := mischiefBracketZone(dndChar.Level)
rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
chain, ambush := mischiefMonsters(tier.Key, bracket, rng)
// Every link, not just the first: a zone-pool entry with no bestiary row
// yields a zero-value template, and a nameless 0-HP monster as fight 2 of a
// mob would read as the attack inexplicably giving up halfway.
if len(chain) == 0 {
p.refundClaimedMischief(c, "no monster available")
return nil
}
for _, m := range chain {
if m.Name == "" {
p.refundClaimedMischief(c, "bestiary miss in the "+bracket.Display+" pool")
return nil
}
}
narration, monsterName, survived := p.runMischiefInterrupt(target, exp, bracket, chain, ambush)
if survived {
p.resolveMischiefSurvived(c, tier, exp, bracket, monsterName, narration)
} else {
p.resolveMischiefDowned(c, exp, monsterName, narration)
}
return nil
}
// refundClaimedMischief unwinds a contract that was already claimed for delivery
// but couldn't be staged. The buyer gets everything back — this is our fault,
// not a fizzle they gambled on.
func (p *AdventurePlugin) refundClaimedMischief(c *mischiefContract, reason string) {
resolveMischiefContract(c.ID, mischiefStatusFizzled, mischiefStatusFizzled)
p.euro.Credit(c.BuyerID, float64(c.Paid), "mischief_refund_unstageable")
p.SendDM(c.BuyerID, fmt.Sprintf(
"😾 Your **%s** never made it out of the gate. Fully refunded: %s.", c.Tier, fmtEuro(c.Paid)))
slog.Warn("mischief: contract unstageable", "contract", c.ID, "reason", reason)
}
// runMischiefInterrupt fights the whole chain and reports whether the target was
// still standing at the end.
//
// Survival is read off the target's HP, not off PlayerWon. The engine's timeout
// is a retreat, not a lethal blow — a target who ran out the clock with HP left
// held the thing off, and a bought monster that merely *outlasted* them has not
// earned a maiming. The chain continues while they stand: a mob is three
// attackers, and beating the first two doesn't mean the third goes home.
func (p *AdventurePlugin) runMischiefInterrupt(
target id.UserID,
exp *Expedition,
bracket ZoneDefinition,
chain []DnDMonsterTemplate,
ambush bool,
) (narration, monsterName string, survived bool) {
var b strings.Builder
last := chain[len(chain)-1].Name
for i, monster := range chain {
// The ambush is the attacker's privilege — it was lying in wait, and the
// target had no reason to expect it. Same one-free-swing approximation the
// harvest interrupt uses, with the same wounded-entrant clamp so it can't
// pre-empt the fight outright.
if ambush && i == 0 {
if dc, _ := LoadDnDCharacter(target); dc != nil {
nick := clampSurpriseNick(
surpriseRoundNick(monster, int(bracket.Tier)), dc.HPCurrent, dc.HPMax)
if nick > 0 {
dc.HPCurrent -= nick
_ = SaveDnDCharacter(dc)
b.WriteString(fmt.Sprintf(
"_It was waiting for you. **%s** opens with a free swing — %d HP._\n",
monster.Name, nick))
}
}
}
preHP, _ := dndHPSnapshot(target)
pres, _, err := p.runZoneCombatRoster(
fightRoster(target), monster, int(bracket.Tier), dungeonCombatPhases, exp.DMMood)
if err != nil {
// Mid-chain build failure. The target keeps whatever HP they had; treat
// them as having survived rather than maiming them on our bug.
slog.Error("mischief: combat error", "target", target, "err", err)
b.WriteString(fmt.Sprintf("_(The %s never quite arrives.)_\n", monster.Name))
return b.String(), last, true
}
postHP, maxHP := dndHPSnapshot(target)
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
switch {
case pres.Seats[0].PlayerWon:
b.WriteString(fmt.Sprintf("✅ **%s** down — %d→%d / %d HP.\n",
monster.Name, preHP, postHP, maxHP))
case postHP > 0:
// The engine's timeout: they held it off without killing it. That still
// pays — but say so plainly, or a stalemate reads as a clean kill and the
// player wonders why the thing is still following them.
b.WriteString(fmt.Sprintf("⏳ **%s** breaks off, still breathing. You're at %d / %d HP.\n",
monster.Name, postHP, maxHP))
}
if postHP <= 0 {
return b.String(), monster.Name, false
}
if i < len(chain)-1 {
b.WriteString("_And it isn't alone._\n")
}
}
return b.String(), last, true
}
// floorMischiefRoster raises every seat the delivery put on the floor back to
// 1 HP. It runs on BOTH outcomes, and that is not belt-and-braces: the leader
// can win a fight their friend went down in, and a mischief delivery
// deliberately skips closeOutZoneWin/closeOutZoneLoss (which is what normally
// marks a 0-HP seat dead). Without this, a dropped party member is left alive at
// 0 HP — a state nothing else in the game produces, and one the gates that read
// `HPCurrent <= 0` (expedition autorun, for one) treat as broken rather than
// dead. Nobody dies for money; nobody gets stuck at zero for it either.
func (p *AdventurePlugin) floorMischiefRoster(target id.UserID) {
for _, uid := range fightRoster(target) {
if isCompanionSeat(uid) {
continue // he takes no wounds home; he has no rows to floor
}
floorHPAtOne(uid)
}
}
// resolveMischiefSurvived pays the target and unseals the buyer.
//
// The purse is a percentage of the base fee — never of what the buyer paid — so
// it is always strictly less than the buyer's outlay. See mischiefPurse.
func (p *AdventurePlugin) resolveMischiefSurvived(
c *mischiefContract, tier mischiefTier, exp *Expedition, bracket ZoneDefinition,
monsterName, narration string,
) {
// The leader lived; a friend of theirs may not have. See floorMischiefRoster.
p.floorMischiefRoster(c.TargetID)
purse := mischiefPurse(c.Fee, tier.PayoutPct)
p.euro.Credit(c.TargetID, float64(purse), "mischief_survived")
// Everything the buyer spent that isn't the purse — the rake, plus the whole
// sign surcharge — is a sink. That is what makes the payout cap bite.
if rake := c.Paid - purse; rake > 0 {
communityPotAdd(rake)
trackTaxPaid(c.BuyerID, rake)
}
// Extras by tier. Treasure rolls against the zone they're actually in — the
// loot is scavenged off a corpse in that dungeon, wherever the thing came from.
var extras []string
switch tier.Key {
case "mob":
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
case "elite":
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
if before, after, err := addRenownXP(c.TargetID, mischiefEliteRenownXP); err == nil {
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
extras = append(extras, "The story of it is already going round town.")
}
case "boss":
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
if before, after, err := addRenownXP(c.TargetID, mischiefBossRenownXP); err == nil {
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
extras = append(extras, "People are going to be telling this one for a while.")
}
for _, item := range consumableCache(int(bracket.Tier), mischiefBossCacheSize) {
it := item
p.grantZoneItem(c.TargetID, &it, "🧪")
}
}
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
var dm strings.Builder
dm.WriteString("😈 **Somebody paid for that.**\n\n")
dm.WriteString(narration)
dm.WriteString(fmt.Sprintf(
"\n🛡 You're still standing, and the coin was never really theirs. **%s** is yours.\n",
fmtEuro(purse)))
dm.WriteString(fmt.Sprintf("It was **%s** who paid to have you killed. Do with that what you like.",
p.DisplayName(c.BuyerID)))
for _, e := range extras {
dm.WriteString("\n_" + e + "_")
}
p.SendDM(c.TargetID, dm.String())
p.SendDM(c.BuyerID, fmt.Sprintf(
"🛡️ **%s walked away from your %s.** They keep %s of your money, and the town knows your name now.",
p.DisplayName(c.TargetID), c.Tier, fmtEuro(purse)))
p.announceMischiefSurvived(c, monsterName, purse)
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeSurvived, purse)
}
// resolveMischiefDowned maims the target: HP floored at 1, expedition
// force-extracted, un-banked coins docked. Nobody dies for money.
//
// The whole fee goes to the community pot — never back to the buyer. A
// successful hit buys glory, not a rebate; refunding it would make repeat
// contracts free and turn the cap into a rounding error.
func (p *AdventurePlugin) resolveMischiefDowned(
c *mischiefContract, exp *Expedition, monsterName, narration string,
) {
// Every seat, not just the leader: a party that went down with them went down
// to a bought monster too, and the no-perma-death rule is about the purchase,
// not about who was holding the torch. Must run BEFORE the extract, which
// releases the party and would leave us nobody to floor.
p.floorMischiefRoster(c.TargetID)
// forcedExtractExpedition retires the region runs and releases the party itself;
// only the zone run has to be closed here.
_ = abandonZoneRun(c.TargetID)
_, tax, err := forcedExtractExpedition(exp.ID, "mischief contract")
if err != nil {
slog.Warn("mischief: force extract failed", "expedition", exp.ID, "err", err)
}
// The un-banked-coin loss. forcedExtractExpedition computes the 20% cut and
// leaves the debit to the caller; for a maiming, this is the caller.
if tax > 0 {
p.euro.Debit(c.TargetID, float64(tax), "mischief_downed_loss")
}
communityPotAdd(c.Paid)
trackTaxPaid(c.BuyerID, c.Paid)
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
var dm strings.Builder
dm.WriteString("😈 **Somebody paid for that.**\n\n")
dm.WriteString(narration)
dm.WriteString(fmt.Sprintf(
"\n💀 **%s** put you down. You wake up on a cart headed home — alive, which is more than the contract asked for.\n",
monsterName))
dm.WriteString("Your expedition is over.")
if tax > 0 {
dm.WriteString(fmt.Sprintf(" Whatever you hadn't banked yet is gone — %s of it.", fmtEuro(tax)))
}
if c.Signed {
dm.WriteString(fmt.Sprintf("\n\nThe contract was signed: **%s** paid for it.", p.DisplayName(c.BuyerID)))
} else {
dm.WriteString("\n\nNobody's saying who paid.")
}
p.SendDM(c.TargetID, dm.String())
p.SendDM(c.BuyerID, fmt.Sprintf(
"💀 Your **%s** found **%s** and put them on the floor. Their expedition is over.\n"+
"_You get nothing back — %s went to the community pot. You did this for the story._",
c.Tier, p.DisplayName(c.TargetID), fmtEuro(c.Paid)))
p.announceMischiefDowned(c, monsterName)
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeDowned, 0)
}

View File

@@ -0,0 +1,564 @@
package plugin
import (
"math/rand/v2"
"testing"
"time"
"gogobee/internal/db"
"github.com/google/uuid"
"maunium.net/go/mautrix/id"
)
func newMischiefTestDB(t *testing.T) {
t.Helper()
db.Close()
if err := db.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
func seedContract(t *testing.T, buyer, target id.UserID, tier string, signed bool) *mischiefContract {
t.Helper()
td, ok := mischiefTierByKey(tier)
if !ok {
t.Fatalf("unknown tier %q", tier)
}
paid := td.Fee
if signed {
paid = mischiefSignedFee(td.Fee)
}
c := &mischiefContract{
ID: uuid.NewString(),
BuyerID: buyer,
TargetID: target,
Tier: td.Key,
Fee: td.Fee,
Paid: paid,
Status: mischiefStatusOpen,
Signed: signed,
Source: "matrix",
CreatedAt: time.Now().UTC(),
WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
}
if err := insertMischiefContract(c); err != nil {
t.Fatalf("seed contract (%s → %s): %v", buyer, target, err)
}
return c
}
// The anti-collusion invariant, and the only reason no danger multiplier is
// needed: whatever the tier and whether or not the buyer signed, the target's
// survival purse is strictly less than what the buyer parted with. Two players
// who try to move money this way lose to !baltransfer, which is free.
func TestMischiefPurseNeverExceedsOutlay(t *testing.T) {
for _, tier := range mischiefTiers {
purse := mischiefPurse(tier.Fee, tier.PayoutPct)
for _, paid := range []int{tier.Fee, mischiefSignedFee(tier.Fee)} {
if purse >= paid {
t.Errorf("%s: purse %d >= buyer outlay %d — collusion beats !baltransfer",
tier.Key, purse, paid)
}
}
if tier.PayoutPct > 0.75 {
t.Errorf("%s: payout %.2f exceeds the 75%% cap", tier.Key, tier.PayoutPct)
}
}
}
// The cap is enforced in code, not just in the table — an escalation (M2) raises
// the payout basis, and nothing it can do may push the purse past 75%.
func TestMischiefPurseHardCap(t *testing.T) {
if got := mischiefPurse(1000, 0.95); got != 750 {
t.Fatalf("mischiefPurse(1000, 0.95) = %d, want 750 (capped)", got)
}
}
func TestMischiefSignedFee(t *testing.T) {
// The tier table's signed prices, as priced in M0.
want := map[string]int{"grunt": 50, "mob": 125, "elite": 438, "boss": 1500}
for _, tier := range mischiefTiers {
if got := mischiefSignedFee(tier.Fee); got != want[tier.Key] {
t.Errorf("%s signed fee = %d, want %d", tier.Key, got, want[tier.Key])
}
}
}
// Exactly one of delivery and fizzle may claim a contract, however many ticks
// race for it. Without this a restart mid-delivery could both maim the target
// and refund the buyer.
func TestMischiefClaimIsExactlyOnce(t *testing.T) {
newMischiefTestDB(t)
c := seedContract(t, "@buyer:x", "@target:x", "elite", false)
if !claimMischiefForDelivery(c.ID) {
t.Fatal("first claim should win")
}
if claimMischiefForDelivery(c.ID) {
t.Error("second claim won — a contract could be delivered twice")
}
if fizzleMischiefContract(c.ID) {
t.Error("fizzle won after the delivery claim — buyer would be refunded a hit that landed")
}
c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false)
if !fizzleMischiefContract(c2.ID) {
t.Fatal("first fizzle should win")
}
if claimMischiefForDelivery(c2.ID) {
t.Error("delivery claimed a fizzled contract — buyer would pay twice")
}
}
// Only a closed window is due, and a resolved contract never comes back.
func TestMischiefDueWindow(t *testing.T) {
newMischiefTestDB(t)
now := time.Now().UTC()
c := seedContract(t, "@buyer:x", "@target:x", "mob", false)
if due := loadMischiefDue(now); len(due) != 0 {
t.Fatalf("contract is due before its window closed: %d", len(due))
}
due := loadMischiefDue(now.Add(mischiefWindow + time.Minute))
if len(due) != 1 || due[0].ID != c.ID {
t.Fatalf("want the contract due after its window; got %d", len(due))
}
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
if due := loadMischiefDue(now.Add(2 * mischiefWindow)); len(due) != 0 {
t.Error("a resolved contract came back around")
}
}
// One live contract per target: two monsters converging on the same person is a
// mugging, not mischief.
func TestMischiefOneLiveContractPerTarget(t *testing.T) {
newMischiefTestDB(t)
target := id.UserID("@target:x")
if liveMischiefForTarget(target) != nil {
t.Fatal("no contract seeded, but one is live")
}
c := seedContract(t, "@buyer:x", target, "grunt", false)
if live := liveMischiefForTarget(target); live == nil || live.ID != c.ID {
t.Fatal("open contract should be live")
}
// A claimed-but-unresolved contract still blocks — the monster is en route.
claimMischiefForDelivery(c.ID)
if liveMischiefForTarget(target) == nil {
t.Error("a delivering contract should still block a second one")
}
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
if liveMischiefForTarget(target) != nil {
t.Error("a resolved contract still reads as live")
}
}
// The post-resolution breather. Also the regression guard for the modernc
// datetime hazard: resolved_at round-trips through the driver's own encoding
// (bound Go time in, time.Time out), so the row has to be a real one — a
// hand-written stamp in some other format would compare lexicographically and
// silently never expire.
func TestMischiefTargetCooldown(t *testing.T) {
newMischiefTestDB(t)
target := id.UserID("@target:x")
now := time.Now().UTC()
if cooling, _ := mischiefTargetCoolingDown(target, now); cooling {
t.Fatal("a target with no history is cooling down")
}
c := seedContract(t, "@buyer:x", target, "grunt", false)
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
cooling, wait := mischiefTargetCoolingDown(target, now)
if !cooling {
t.Fatal("a just-resolved target should be cooling down")
}
// resolved_at is stamped a hair after `now`, so allow a second of slack.
if wait <= 0 || wait > mischiefTargetCooldown+time.Second {
t.Errorf("wait = %s, want within (0, %s]", wait, mischiefTargetCooldown)
}
if cooling, _ := mischiefTargetCoolingDown(target, now.Add(mischiefTargetCooldown+time.Minute)); cooling {
t.Error("still cooling down after the cooldown elapsed")
}
}
// Rate limits: contracts per buyer per day, and boss-tier per target per week.
// Fizzled contracts count against the buyer — the limit is on how often you may
// point a monster at someone, not on how often it connects.
func TestMischiefRateLimits(t *testing.T) {
newMischiefTestDB(t)
buyer := id.UserID("@buyer:x")
now := time.Now().UTC()
dayAgo := now.Add(-24 * time.Hour)
if n := mischiefBuyerCountSince(buyer, dayAgo); n != 0 {
t.Fatalf("fresh buyer count = %d, want 0", n)
}
c1 := seedContract(t, buyer, "@a:x", "grunt", false)
seedContract(t, buyer, "@b:x", "mob", false)
fizzleMischiefContract(c1.ID)
if n := mischiefBuyerCountSince(buyer, dayAgo); n != mischiefBuyerDailyCap {
t.Errorf("buyer count = %d, want %d (a fizzle still counts)", n, mischiefBuyerDailyCap)
}
target := id.UserID("@whale:x")
weekAgo := now.Add(-mischiefBossPerTargetWindow)
if n := mischiefBossCountSince(target, weekAgo); n != 0 {
t.Fatalf("fresh boss count = %d, want 0", n)
}
elite := seedContract(t, "@other:x", target, "elite", false)
if n := mischiefBossCountSince(target, weekAgo); n != 0 {
t.Error("an elite contract counted against the boss-per-week limit")
}
// Resolve it first: only one contract may be live against a target at a time,
// and the database enforces that (idx_mischief_one_live_per_target).
resolveMischiefContract(elite.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
seedContract(t, "@other:x", target, "boss", false)
if n := mischiefBossCountSince(target, weekAgo); n != 1 {
t.Errorf("boss count = %d, want 1", n)
}
}
// The database, not a read-then-write check, is what stops two buyers racing a
// monster at the same target. The placement path holds only the buyer's lock, so
// without this index both inserts would land and the target would be hit twice.
func TestMischiefSecondLiveContractIsRejected(t *testing.T) {
newMischiefTestDB(t)
target := id.UserID("@target:x")
seedContract(t, "@buyer1:x", target, "grunt", false)
second := &mischiefContract{
ID: uuid.NewString(), BuyerID: "@buyer2:x", TargetID: target,
Tier: "elite", Fee: 350, Paid: 350, Status: mischiefStatusOpen,
Source: "matrix", CreatedAt: time.Now().UTC(),
WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
}
if err := insertMischiefContract(second); err == nil {
t.Fatal("a second live contract was accepted — the target can be hit twice at once")
}
}
// A delivery that dies mid-flight (restart, panic) must not strand the contract:
// the row would block the target forever and the buyer's money would be gone.
func TestMischiefStaleDeliverySweep(t *testing.T) {
newMischiefTestDB(t)
now := time.Now().UTC()
c := seedContract(t, "@buyer:x", "@target:x", "boss", false)
if !claimMischiefForDelivery(c.ID) {
t.Fatal("claim should win")
}
// Inside the grace period a slow delivery is left alone.
if stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow)); len(stale) != 0 {
t.Errorf("a delivery still inside its grace window was called stranded: %d", len(stale))
}
stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow + mischiefDeliveryGrace + time.Minute))
if len(stale) != 1 || stale[0].ID != c.ID {
t.Fatalf("stranded delivery not found: %d", len(stale))
}
if !abandonStaleMischief(c.ID) {
t.Fatal("abandon should win on a delivering row")
}
if abandonStaleMischief(c.ID) {
t.Error("abandon won twice — the buyer would be refunded twice")
}
// And the target is free again.
if liveMischiefForTarget("@target:x") != nil {
t.Error("a swept contract still blocks the target")
}
}
// The monster is priced against the TARGET's bracket, not the dungeon they are
// standing in — that is what the M0 fee table measured.
func TestMischiefBracketZones(t *testing.T) {
for _, tc := range []struct {
level int
tier ZoneTier
}{{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3}, {13, 4}, {17, 4}, {18, 5}, {20, 5}} {
zone := mischiefBracketZone(tc.level)
if zone.Tier != tc.tier {
t.Errorf("level %d draws from tier %d, want %d", tc.level, zone.Tier, tc.tier)
}
}
}
// The fight shapes the fee table was priced against. Changing any of these
// invalidates the pricing, so they are pinned.
func TestMischiefMonsterChains(t *testing.T) {
zone := mischiefBracketZone(10) // tier 3
rng := rand.New(rand.NewPCG(0x6d69, 1))
for _, tc := range []struct {
tier string
wantLen int
wantAmbush bool
}{
{"grunt", 1, false},
{"mob", 3, false},
{"elite", 1, true},
{"boss", 1, true},
} {
chain, ambush := mischiefMonsters(tc.tier, zone, rng)
if len(chain) != tc.wantLen {
t.Errorf("%s: chain of %d, want %d", tc.tier, len(chain), tc.wantLen)
}
if ambush != tc.wantAmbush {
t.Errorf("%s: ambush = %v, want %v", tc.tier, ambush, tc.wantAmbush)
}
for _, m := range chain {
if m.Name == "" {
t.Errorf("%s: chain holds an empty monster — the bestiary lookup missed", tc.tier)
}
}
}
// Boss tier sends the zone's actual boss; elite sends something flagged elite.
if chain, _ := mischiefMonsters("boss", zone, rng); chain[0].ID != zone.Boss.BestiaryID {
t.Errorf("boss tier sent %q, want the zone boss %q", chain[0].ID, zone.Boss.BestiaryID)
}
elite, _ := mischiefMonsters("elite", zone, rng)
found := false
for _, e := range zone.Enemies {
if e.BestiaryID == elite[0].ID && e.IsElite {
found = true
}
}
if !found {
t.Errorf("elite tier sent %q, which isn't an elite in %s", elite[0].ID, zone.ID)
}
}
// ── End-to-end: the delivery path against a real character ───────────────────
// seedMischiefTarget builds a real, playable adventurer on a live expedition —
// the thing a contract actually lands on. Unit tests on the contract row prove
// nothing about the fight; these drive runMischiefInterrupt and the close-outs.
func seedMischiefTarget(t *testing.T, uid id.UserID, level, hp int) *Expedition {
t.Helper()
if err := createAdvCharacter(uid, "target"+uid.Localpart()); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
if err := SaveDnDCharacter(&DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
HPMax: hp, HPCurrent: hp, ArmorClass: 18,
}); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
if _, err := db.Get().Exec(`
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, coins_earned)
VALUES (?, ?, 'goblin_warrens', ?, 'active', ?, 500)`,
"exp-"+uid.Localpart(), string(uid), run.RunID, time.Now().UTC()); err != nil {
t.Fatalf("seed expedition: %v", err)
}
exp, err := getActiveExpedition(uid)
if err != nil || exp == nil {
t.Fatalf("getActiveExpedition: %v", err)
}
return exp
}
// A grunt against a healthy level-5 fighter: the fight actually runs, it costs
// HP, and they live. This is the "mostly theatre" tier doing what it was priced
// to do.
func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) {
newMischiefTestDB(t)
p := &AdventurePlugin{}
uid := id.UserID("@grunted:example.org")
exp := seedMischiefTarget(t, uid, 5, 60)
bracket := mischiefBracketZone(5)
chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2)))
_, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush)
if !survived {
t.Fatalf("a level-5 fighter at full HP died to a grunt (%s) — the tier is mispriced", monster)
}
hp, _ := dndHPSnapshot(uid)
if hp <= 0 {
t.Errorf("survivor is at %d HP", hp)
}
}
// The boss tier is the dangerous one, and the one where the no-perma-death rule
// has to hold. A level-3 caster-grade sheet against a tier-1 boss goes down —
// and must come back up at 1 HP with the expedition force-extracted, never dead.
//
// This is the property that separates mischief from paid murder: it is bought,
// and it lands while the victim may well be asleep.
func TestMischiefDelivery_DownedTargetIsMaimedNotKilled(t *testing.T) {
newMischiefTestDB(t)
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
uid := id.UserID("@doomed:example.org")
// 1 HP: whatever the dice do, this fight ends with them on the floor.
exp := seedMischiefTarget(t, uid, 3, 1)
c := seedContract(t, "@buyer:example.org", uid, "boss", false)
if !claimMischiefForDelivery(c.ID) {
t.Fatal("claim should win")
}
if err := p.deliverMischief(c, exp); err != nil {
t.Fatalf("deliverMischief: %v", err)
}
// Alive, floored, and carried home.
char, err := loadAdvCharacter(uid)
if err != nil || char == nil {
t.Fatal("target's character is gone")
}
if !char.Alive {
t.Fatal("a PURCHASED monster killed a player outright — mischief must maim, never murder")
}
if hp, _ := dndHPSnapshot(uid); hp != 1 {
t.Errorf("downed target is at %d HP, want the 1 HP floor", hp)
}
if e, _ := getActiveExpedition(uid); e != nil {
t.Error("the expedition survived a downing — it must be force-extracted")
}
// The buyer gets nothing back; the whole fee is a sink.
got, err := mischiefContractByID(c.ID)
if err != nil || got == nil {
t.Fatal("contract row vanished")
}
if got.Status != mischiefStatusDelivered || got.Outcome != mischiefOutcomeDowned {
t.Errorf("contract resolved as %s/%s, want delivered/downed", got.Status, got.Outcome)
}
if pot := communityPotBalance(); pot < c.Paid {
t.Errorf("community pot holds %d, want at least the %d fee — a landed hit refunds nobody", pot, c.Paid)
}
if bal := p.euro.GetBalance(c.BuyerID); bal > 0 {
t.Errorf("buyer was credited %.0f on a successful hit; glory only", bal)
}
}
// Survival pays the target out of the buyer's money, and never more than the
// buyer spent. Driven through the real close-out, not the arithmetic helper.
func TestMischiefDelivery_SurvivalPaysTheTarget(t *testing.T) {
newMischiefTestDB(t)
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
uid := id.UserID("@tough:example.org")
exp := seedMischiefTarget(t, uid, 12, 400) // comfortably survives a tier-1 grunt
c := seedContract(t, "@buyer:example.org", uid, "grunt", false)
if !claimMischiefForDelivery(c.ID) {
t.Fatal("claim should win")
}
before := p.euro.GetBalance(uid)
if err := p.deliverMischief(c, exp); err != nil {
t.Fatalf("deliverMischief: %v", err)
}
got, _ := mischiefContractByID(c.ID)
if got.Outcome != mischiefOutcomeSurvived {
t.Fatalf("a level-12 fighter with 400 HP lost to a tier-1 grunt (outcome %s)", got.Outcome)
}
tier, _ := mischiefTierByKey("grunt")
purse := mischiefPurse(tier.Fee, tier.PayoutPct)
if gained := int(p.euro.GetBalance(uid) - before); gained != purse {
t.Errorf("survivor was paid %d, want the %d purse", gained, purse)
}
if purse >= c.Paid {
t.Errorf("purse %d >= outlay %d", purse, c.Paid)
}
// The remainder is a sink, not a rebate.
if pot := communityPotBalance(); pot != c.Paid-purse {
t.Errorf("pot holds %d, want the %d remainder", pot, c.Paid-purse)
}
// The expedition is untouched — they fought it off and walk on.
if e, _ := getActiveExpedition(uid); e == nil {
t.Error("surviving a contract ended the expedition")
}
}
// An expedition that ends before the window closes fizzles: the buyer is
// refunded 90%, the town rakes the rest, and nobody is attacked.
func TestMischiefDelivery_FizzlesWhenTargetWentHome(t *testing.T) {
newMischiefTestDB(t)
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
uid := id.UserID("@gone:example.org")
seedMischiefTarget(t, uid, 5, 60)
c := seedContract(t, "@buyer:example.org", uid, "elite", false)
// They extract before the monster arrives.
if _, _, err := forcedExtractExpedition("exp-"+uid.Localpart(), "went home"); err != nil {
t.Fatalf("forcedExtractExpedition: %v", err)
}
p.fireMischiefDeliveries(time.Now().UTC().Add(mischiefWindow + time.Minute))
got, _ := mischiefContractByID(c.ID)
if got.Status != mischiefStatusFizzled {
t.Fatalf("contract status %s, want fizzled — the dungeon was empty", got.Status)
}
refund := int(float64(c.Paid) * mischiefFizzleRefund)
if bal := int(p.euro.GetBalance(c.BuyerID)); bal != refund {
t.Errorf("buyer refunded %d, want %d (90%%)", bal, refund)
}
if pot := communityPotBalance(); pot != c.Paid-refund {
t.Errorf("pot raked %d, want %d", pot, c.Paid-refund)
}
if hp, max := dndHPSnapshot(uid); hp != max {
t.Errorf("a fizzled contract still hurt the target (%d/%d HP)", hp, max)
}
}
// The limbo state. A leader can win a fight their friend went down in — and a
// mischief delivery deliberately skips the close-outs that would normally mark a
// 0-HP seat dead. So the seat must be floored on BOTH outcomes, or the member is
// left alive at 0 HP: not dead enough for the hospital, too dead to act, and
// read as broken by every gate that tests `HPCurrent <= 0`.
func TestMischiefDelivery_SurvivalFloorsADroppedPartyMember(t *testing.T) {
newMischiefTestDB(t)
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
leader := id.UserID("@leader:example.org")
member := id.UserID("@member:example.org")
exp := seedMischiefTarget(t, leader, 12, 400) // wins comfortably
seatLeaderFixture(t, exp.ID)
if err := createAdvCharacter(member, "member"); err != nil {
t.Fatal(err)
}
if err := SaveDnDCharacter(&DnDCharacter{
UserID: member, Race: RaceHuman, Class: ClassFighter, Level: 3,
STR: 14, DEX: 12, CON: 12, INT: 10, WIS: 10, CHA: 10,
HPMax: 30, HPCurrent: 30, ArmorClass: 12,
}); err != nil {
t.Fatal(err)
}
if err := joinParty(exp.ID, member); err != nil {
t.Fatalf("joinParty: %v", err)
}
// Put the member on the floor the way the fight would.
if _, err := db.Get().Exec(
`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(member)); err != nil {
t.Fatal(err)
}
c := seedContract(t, "@buyer:example.org", leader, "grunt", false)
if !claimMischiefForDelivery(c.ID) {
t.Fatal("claim should win")
}
if err := p.deliverMischief(c, exp); err != nil {
t.Fatalf("deliverMischief: %v", err)
}
got, _ := mischiefContractByID(c.ID)
if got.Outcome != mischiefOutcomeSurvived {
t.Fatalf("leader lost; this test needs a survival (outcome %s)", got.Outcome)
}
hp, _ := dndHPSnapshot(member)
if hp <= 0 {
t.Errorf("dropped party member left at %d HP on a survived contract — alive, but stuck at zero", hp)
}
if mc, _ := loadAdvCharacter(member); mc == nil || !mc.Alive {
t.Error("a bought monster killed a party member — mischief maims, it never murders")
}
}

View File

@@ -190,7 +190,17 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
}
// Room announcement
// Room announcement — but not for a player who isn't there.
//
// A bored adventurer (gogobee_boredom_plan.md) auto-harvests ore and junk
// into inventory on every run, and Robbie takes all of it, every day. Left
// alone, each abandoned character would file a public bulletin every single
// day, indefinitely, about somebody who stopped playing weeks ago. He still
// visits and still pays — that income is what keeps the neglected adventurer
// walking — he just doesn't announce a house with nobody in it.
if playerIsIdle(userID, time.Now().UTC()) {
return
}
gr := gamesRoom()
if gr != "" {
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)

View File

@@ -405,8 +405,9 @@ func (p *AdventurePlugin) midnightReset() error {
return fmt.Errorf("load chars: %w", err)
}
today := time.Now().UTC().Format("2006-01-02")
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
now := time.Now().UTC()
today := now.Format("2006-01-02")
yesterday := now.Add(-24 * time.Hour).Format("2006-01-02")
// Unified activity oracle — same source the daily report uses. Unions
// adventure_activity_log + dnd_zone_run + dnd_expedition_log, so
@@ -457,25 +458,32 @@ func (p *AdventurePlugin) midnightReset() error {
continue
}
// Activity happened on the player's behalf without an explicit tap —
// autopilot walked rooms, expedition log gained beats, a fight session
// is still locked open. Hold the streak: no bump, no shame, no decay.
// The player engaged earlier to kick this off; autopilot is a feature,
// not a way to game streaks, and absence of taps shouldn't strip
// progress they earned through real play.
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
continue
}
// Safety net for live state the activity logs don't reflect yet
// (e.g. an active expedition that's been quiet today, or a combat
// session locked open across midnight without a log append).
if exp, err := getActiveExpedition(char.UserID); err != nil {
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
} else if exp != nil {
continue
}
if hasActiveCombatSession(char.UserID) {
continue
// Every hold below rests on one premise: the player engaged earlier to
// kick this off, so the autopilot finishing the job shouldn't cost them
// their streak. A boredom expedition has no such origin — nobody kicked
// it off, and its player still hasn't come back. It earns them nothing
// and shields them from nothing (gogobee_boredom_plan.md §6).
if !isBoredomDriven(char.UserID, now) {
// Activity happened on the player's behalf without an explicit tap —
// autopilot walked rooms, expedition log gained beats, a fight session
// is still locked open. Hold the streak: no bump, no shame, no decay.
// The player engaged earlier to kick this off; autopilot is a feature,
// not a way to game streaks, and absence of taps shouldn't strip
// progress they earned through real play.
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
continue
}
// Safety net for live state the activity logs don't reflect yet
// (e.g. an active expedition that's been quiet today, or a combat
// session locked open across midnight without a log append).
if exp, err := getActiveExpedition(char.UserID); err != nil {
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
} else if exp != nil {
continue
}
if hasActiveCombatSession(char.UserID) {
continue
}
}
// Truly idle — shame DM + streak halve.

View File

@@ -695,7 +695,7 @@ func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBoss
// No death / no hospital: floor the fighter at 1 HP so a loss never reads as
// a corpse. runZoneCombat already persisted the real HP cost.
battered := worldBossFloorHP(userID)
battered := floorHPAtOne(userID)
dmg := result.EnemyEntryHP - result.EnemyEndHP
if dmg < 0 {
@@ -718,10 +718,11 @@ func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBoss
}, nil
}
// worldBossFloorHP raises a player to 1 HP if the bout left them at zero, and
// floorHPAtOne raises a player to 1 HP if the fight left them at zero, and
// reports whether it had to. Keeps "no death" honest without undoing the real
// HP cost of a bout the player mostly survived.
func worldBossFloorHP(userID id.UserID) bool {
// HP cost of a bout the player mostly survived. Shared by the two no-death
// fights: the world-boss bout and a Mischief Makers delivery.
func floorHPAtOne(userID id.UserID) bool {
cur, _ := dndHPSnapshot(userID)
if cur > 0 {
return false

View File

@@ -291,13 +291,13 @@ func TestWorldBossFloorHP(t *testing.T) {
newWorldBossTestDB(t)
uid := id.UserID("@floor:test.invalid")
fightableChar(t, uid)
if worldBossFloorHP(uid) {
if floorHPAtOne(uid) {
t.Error("floor should be a no-op above 0 HP")
}
if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
t.Fatal(err)
}
if !worldBossFloorHP(uid) {
if !floorHPAtOne(uid) {
t.Error("floor should raise a 0-HP fighter")
}
if cur, _ := dndHPSnapshot(uid); cur != 1 {

View File

@@ -0,0 +1,47 @@
package plugin
import (
"log/slog"
"gogobee/internal/db"
)
// bootstrapBoredomClock seeds player_meta.last_player_action_at for rows that
// predate the column (gogobee_boredom_plan.md §1).
//
// Without this, the column is NULL for every existing player on the deploy that
// adds it, and loadBoredomCandidates falls back to created_at — which is when
// the character was *made*, not when it was last played. Every alive character
// on the server is therefore months past the idle threshold on the first tick
// after the deploy, and the whole player base gets marched into a dungeon at
// once, coins debited, thirty minutes after restart. A player who typed a
// command a minute before the deploy is no exception: the clock has never heard
// of them.
//
// last_active_at is the best proxy we have for "the character did something
// recently" and is exactly right at deploy time: recent for anyone still
// playing, stale for anyone who isn't. It is unusable as the clock itself (the
// autopilot bumps it through saveAdvCharacter, so a bored character would keep
// refreshing its own idle clock — see markPlayerAction), which is why this is a
// one-time seed and not a fallback in the query.
//
// Re-running on every boot is safe and intentionally a no-op:
// - a row seeded here, or stamped by a real action, is no longer NULL;
// - a character the boredom ticker has already sent out (last_boredom_at set)
// is skipped, so a restart mid-boredom can't hand it a fresh idle clock off
// the autopilot's own writes.
func bootstrapBoredomClock() {
res, err := db.Get().Exec(`
UPDATE player_meta
SET last_player_action_at = COALESCE(last_active_at, created_at)
WHERE last_player_action_at IS NULL
AND last_boredom_at IS NULL
AND COALESCE(last_active_at, created_at) IS NOT NULL`)
if err != nil {
slog.Error("bootstrap: boredom clock seed failed", "err", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
slog.Warn("bootstrap: seeded boredom idle clock from last_active_at", "rows", n)
}
}

View File

@@ -579,9 +579,14 @@ func markAdventureDead(userID id.UserID, source, location string) {
emitDeathNews(userID, location)
}
// emitDeathNews files a PRIORITY death dispatch to Pete's adventure news. No-op
// emitDeathNews files a BULLETIN death dispatch to Pete's adventure news. No-op
// unless the seam is enabled. Uses the character name (never the Matrix handle);
// skips silently if the name is unknown.
//
// Bulletin, not priority: the room watched the character go down in TwinBee's
// narration, and a priority fact is what makes Pete post a live beat — he'd be
// breaking the news to the people who were there. The site row and the digest
// still carry it.
func emitDeathNews(userID id.UserID, location string) {
// Gate before the char lookups: markAdventureDead fires per-member on a
// party wipe and in the sim harness, so skip the DB reads when disabled.
@@ -597,7 +602,7 @@ func emitDeathNews(userID id.UserID, location string) {
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
EventType: "death",
Tier: "priority",
Tier: "bulletin",
Subject: name,
Zone: location,
Level: lvl,

View File

@@ -383,44 +383,10 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
}
zone := zoneForCaps
// Holiday perk: a complimentary standard pack is added to the supplies
// snapshot without inflating the coin cost. Bypasses the per-tier cap
// on purpose — it's a freebie on top of whatever the player bought.
suppliesPurchase := purchase
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
suppliesPurchase.StandardPacks += pk
}
supplies := makeSupplies(zone.Tier, suppliesPurchase)
// Debit coins; bail on debit failure (race / cap).
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(zoneID)) {
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
}
exp, err := startExpedition(ctx.Sender, zoneID, "", supplies)
_, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting")
if err != nil {
// Refund on persistence failure.
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund")
return p.SendDM(ctx.Sender, "Couldn't start expedition: "+err.Error())
return p.SendDM(ctx.Sender, err.Error())
}
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
// harvest depends on this; the existing zone-run !advance/!retreat
// commands now operate on this run while the expedition is active.
if _, err := ensureRegionRun(exp, c.Level); err != nil {
// Refund and tear the expedition row back down — without a
// linked run, harvest and rooms can't function.
_ = abandonExpedition(ctx.Sender)
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund (run-spawn failed)")
return p.SendDM(ctx.Sender, "Couldn't outfit the first region: "+err.Error())
}
// Log the start with prewritten flavor.
startLine := flavor.Pick(flavor.ExpeditionStart)
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
markActedToday(ctx.Sender)
var b strings.Builder
@@ -442,6 +408,65 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
return p.SendDM(ctx.Sender, b.String())
}
// beginExpedition performs the non-interactive half of starting an expedition:
// supply freebies, the coin debit, persistence, the starting region's run, and
// the opening log entry. It refunds and tears down on every failure path, so a
// caller holding an error owes the player nothing.
//
// Callers own the balance check, the eligibility guards, and the player-facing
// messaging. This is the shared transaction under `!expedition start` and the
// boredom ticker (gogobee_boredom_plan.md §4); the returned errors are written
// as complete player-facing sentences so both callers can surface them as-is.
//
// It deliberately does NOT call markActedToday — an expedition the player did
// not ask for must not spend their daily action or count as them showing up.
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
if p.euro == nil {
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
}
cost := float64(purchase.Cost())
// Holiday perk: a complimentary standard pack is added to the supplies
// snapshot without inflating the coin cost. Bypasses the per-tier cap
// on purpose — it's a freebie on top of whatever the player bought.
suppliesPurchase := purchase
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
suppliesPurchase.StandardPacks += pk
}
supplies := makeSupplies(zone.Tier, suppliesPurchase)
// Debit coins; bail on debit failure (race / cap).
if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) {
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
}
exp, err := startExpedition(uid, zone.ID, "", supplies)
if err != nil {
// Refund on persistence failure.
p.euro.Credit(uid, cost, "expedition outfitting refund")
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
}
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
// harvest depends on this; the existing zone-run !advance/!retreat
// commands now operate on this run while the expedition is active.
if _, err := ensureRegionRun(exp, charLevel); err != nil {
// Refund and tear the expedition row back down — without a
// linked run, harvest and rooms can't function.
_ = abandonExpedition(uid)
p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)")
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
}
// Log the start with prewritten flavor.
startLine := flavor.Pick(flavor.ExpeditionStart)
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
return exp, supplies, startLine, nil
}
// raidContentWarning returns a TwinBee-voiced heads-up for zones whose
// boss is tuned for a party rather than a solo adventurer. T5 zones
// (Dragon's Lair / Abyss Portal) have boss HP / damage curves that the

View File

@@ -0,0 +1,427 @@
//go:build prodexercise
package plugin
// Headless end-to-end exercise of the bored-adventurer path against a COPY of
// the prod DB. This covers the one seam the unit tests can't reach: the
// tryBoredomStart → beginExpedition → autopilot chain, which needs a live
// EuroPlugin and a real character with real gear.
//
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
// go test -tags prodexercise -run TestExerciseBoredomProd -v ./internal/plugin/
//
// As with TestExerciseNewFeaturesProd, the DB copy is re-copied into t.TempDir()
// before opening, so even the copy is left pristine and nothing can reach prod.
//
// What it has to prove:
// 1. The idle clock picks out exactly the idle players — a player who acted an
// hour ago must NOT be swept up. (playerIsIdle fails open, so a regression
// here marches the whole server into a dungeon at once.)
// 2. A bored run actually starts against a real character: real zone, lean
// supplies, coins debited.
// 3. It buys and equips nothing. That's the whole mechanic.
// 4. The expedition is flagged boredom=1, so the idle reaper won't let it
// shield an absent player's streak.
// 5. It re-entrantly refuses: a second tick inside the cooldown does nothing.
// 6. The autopilot will actually walk it.
import (
"fmt"
"os"
"path/filepath"
"sort"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func TestExerciseBoredomProd(t *testing.T) {
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
if srcDir == "" {
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
}
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
t.Skipf("no gogobee.db in %s", srcDir)
}
tmp := t.TempDir()
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
}
db.Close()
if err := db.Init(tmp); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
simOmenDisabled = true
euro := NewEuroPlugin(nil)
xp := NewXPPlugin(nil)
p := NewAdventurePlugin(nil, euro, xp)
sink := &captureSink{}
p.Sink = sink
mark := 0
drain := func() {
for _, m := range sink.msgs[mark:] {
who := string(m.ToUser)
if who == "" {
who = "room:" + string(m.ToRoom)
}
t.Logf(" → [%s]\n%s", who, indent(m.Text))
}
mark = len(sink.msgs)
}
chars := runnableChars(t)
if len(chars) < 2 {
t.Fatalf("need 2+ runnable characters in the DB copy, got %d", len(chars))
}
// A pinned clock, 14:00 UTC today: outside the ambient quiet windows (06:00
// briefing / 21:00 recap) that fireExpeditionBoredom deliberately refuses to
// talk over, so the run is deterministic regardless of when we invoke it.
now := time.Now().UTC().Truncate(24 * time.Hour).Add(14 * time.Hour)
if inAmbientQuietWindow(now) {
t.Fatal("pinned clock landed in the quiet window — pick another hour")
}
// Clear the decks: nobody in the copy should be mid-expedition when we start,
// or the structural guards will (correctly) refuse and prove nothing.
for _, c := range chars {
clearExpeditionState(t, c.uid)
healToFull(t, c.uid)
euro.Credit(c.uid, 2000, "boredom exercise bankroll")
}
// The control arm. Without one, "everybody went" and "the clock works" look
// identical — and the failure mode we're hunting (playerIsIdle failing open)
// produces exactly the former.
active := chars[0]
idle := chars[1:]
setLastAction(t, active.uid, now.Add(-1*time.Hour))
for _, c := range idle {
setLastAction(t, c.uid, now.Add(-72*time.Hour))
}
// Everyone else in the DB is left alone but must not be dragged along either;
// snapshot who has an expedition now so we can diff at the end.
before := activeExpeditionOwners(t)
t.Logf("──────── clock ────────")
t.Logf(" control (acted 1h ago): %s L%d %s", active.uid, active.level, active.class)
for _, c := range idle {
t.Logf(" idle (72h silent): %s L%d %s", c.uid, c.level, c.class)
}
// ── 1. The candidate query ──────────────────────────────────────────────
cands, err := loadBoredomCandidates(now)
if err != nil {
t.Fatalf("loadBoredomCandidates: %v", err)
}
candSet := map[id.UserID]bool{}
for _, u := range cands {
candSet[u] = true
}
t.Logf(" loadBoredomCandidates → %d players", len(cands))
if candSet[active.uid] {
t.Errorf("control %s acted an hour ago but is a boredom candidate — the idle clock is not being read", active.uid)
}
for _, c := range idle {
if !candSet[c.uid] {
t.Errorf("idle %s has been silent 72h but is not a candidate", c.uid)
}
if playerIsIdle(active.uid, now) {
t.Fatalf("playerIsIdle says the control player is idle — it is failing open, ABORT (this is the bug that marches the whole server into a dungeon)")
}
if !playerIsIdle(c.uid, now) {
t.Errorf("playerIsIdle(%s) = false after 72h of silence", c.uid)
}
}
// ── 2/3/4. The run itself ───────────────────────────────────────────────
type snap struct {
balance float64
gear string
}
pre := map[id.UserID]snap{}
for _, c := range chars {
pre[c.uid] = snap{balance: euro.GetBalance(c.uid), gear: gearFingerprint(t, c.uid)}
}
t.Logf("──────── fireExpeditionBoredom ────────")
p.fireExpeditionBoredom(now)
drain()
for _, c := range idle {
exp, err := getActiveExpedition(c.uid)
if err != nil || exp == nil {
t.Errorf("%s (L%d): no expedition started (err=%v) — the boredom start path is broken", c.uid, c.level, err)
continue
}
zone, ok := getZone(exp.ZoneID)
if !ok {
t.Errorf("%s: started an unknown zone %q", c.uid, exp.ZoneID)
continue
}
spent := pre[c.uid].balance - euro.GetBalance(c.uid)
lean := loadoutPurchase(zone.Tier, LoadoutLean)
t.Logf(" %s L%d → %s (T%d) — spent %.0f coins (lean = %d)",
c.uid, c.level, zone.Display, int(zone.Tier), spent, lean.Cost())
// In band.
if c.level < zone.LevelMin || c.level > zone.LevelMax {
t.Errorf("%s L%d sent to %s, band %d%d — out of band",
c.uid, c.level, zone.ID, zone.LevelMin, zone.LevelMax)
}
// Lean supplies, exactly. Anything else means it went shopping.
if int(spent) != lean.Cost() {
t.Errorf("%s spent %.0f coins, lean pack for T%d is %d — it bought something it shouldn't have",
c.uid, spent, int(zone.Tier), lean.Cost())
}
// Gear untouched. This is the mechanic, not a nicety.
if got := gearFingerprint(t, c.uid); got != pre[c.uid].gear {
t.Errorf("%s: equipment changed across a bored run.\n was: %s\n now: %s", c.uid, pre[c.uid].gear, got)
}
// Flagged, so the idle reaper won't let it hold their streak.
if !expeditionIsBoredom(t, exp.ID) {
t.Errorf("%s: expedition %s not flagged boredom=1 — it will shield an absent player's streak", c.uid, exp.ID)
}
if !isBoredomDriven(c.uid, now) {
t.Errorf("isBoredomDriven(%s) = false right after a bored start", c.uid)
}
// Raid only where there was no alternative.
if w := raidContentWarning(zone.ID); w != "" && c.level < 13 {
t.Errorf("%s L%d was sent into raid zone %s while non-raid zones were in band", c.uid, c.level, zone.ID)
}
}
// The control player must be exactly where we left them.
if exp, _ := getActiveExpedition(active.uid); exp != nil {
t.Errorf("control %s acted an hour ago and was still sent on an expedition (%s)", active.uid, exp.ZoneID)
}
if b := euro.GetBalance(active.uid); b != pre[active.uid].balance {
t.Errorf("control %s was charged %.0f coins for an expedition they didn't take",
active.uid, pre[active.uid].balance-b)
}
// The sweep reaches every idle player in the DB, not just the two we staged —
// that's the feature working, not a leak. The invariant that actually matters
// is that *everyone who left was idle*. (runnableChars only lists characters
// with the legacy adventure_characters row, so the DB holds idle players it
// doesn't return; they're still real characters and they're right to go.)
departed := diffOwners(activeExpeditionOwners(t), before)
for _, uid := range departed {
if !playerIsIdle(uid, now) {
t.Errorf("%s was sent on an expedition but is NOT idle — the clock is not gating the sweep", uid)
}
}
t.Logf(" departed: %d of %d candidates", len(departed), len(cands))
// The gap between candidates and departures is the structural guards doing
// their job (mid-run, resting, unfinished setup, broke). Name them, so a
// silent refusal can't hide here.
left := map[id.UserID]bool{}
for _, uid := range departed {
left[uid] = true
}
for _, uid := range cands {
if !left[uid] {
t.Logf(" stayed home: %-28s %s", uid, whyNoBoredomRun(uid))
}
}
// ── 5. The cooldown ─────────────────────────────────────────────────────
t.Logf("──────── second tick (must be a no-op) ────────")
balBefore := map[id.UserID]float64{}
for _, c := range idle {
balBefore[c.uid] = euro.GetBalance(c.uid)
}
p.fireExpeditionBoredom(now.Add(30 * time.Minute))
drain()
for _, c := range idle {
if b := euro.GetBalance(c.uid); b != balBefore[c.uid] {
t.Errorf("%s was charged %.0f more coins on a second tick inside the cooldown — the CAS claim isn't holding",
c.uid, balBefore[c.uid]-b)
}
}
// ── 6. Does it actually walk? ───────────────────────────────────────────
t.Logf("──────── autopilot walks the bored run ────────")
for _, c := range idle {
exp, _ := getActiveExpedition(c.uid)
if exp == nil {
continue
}
ctx := MessageContext{Sender: c.uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: ""}
// Several segments, as the ambient ticker would over a day. One call can
// legitimately stop early (stopHarvestCombat, a fork, a boss doorway); the
// question is whether the run keeps making progress when it's called again.
total := 0
for seg := 0; seg < 4; seg++ {
r := p.runAutopilotWalkDriven(ctx, 6)
if r.initErr != "" {
t.Errorf("%s: autopilot refused to walk the bored expedition: %s", c.uid, r.initErr)
break
}
total += r.rooms
t.Logf(" %s seg%d: +%d rooms (total %d), stopped: %s", c.uid, seg, r.rooms, total, stopName(r.reason))
if r.reason == stopComplete || r.reason == stopEnded {
break
}
}
if total == 0 {
t.Errorf("%s: the bored expedition never took a single step", c.uid)
}
}
drain()
}
// ---- helpers ---------------------------------------------------------------
// clearExpeditionState puts the character back on the doorstep: no expedition,
// no zone run, no combat session. The prod copy is a live snapshot, so some
// characters are mid-run — and the structural guards would (correctly) refuse
// to start a bored run on top of that, which would test nothing.
func clearExpeditionState(t *testing.T, uid id.UserID) {
t.Helper()
for _, q := range []string{
`UPDATE dnd_expedition SET status = 'complete' WHERE user_id = ? AND status IN ('active','extracted')`,
`DELETE FROM dnd_zone_run WHERE user_id = ?`,
`DELETE FROM combat_session WHERE user_id = ?`,
`UPDATE player_meta SET last_boredom_at = NULL WHERE user_id = ?`,
} {
if _, err := db.Get().Exec(q, string(uid)); err != nil {
t.Logf(" clearExpeditionState(%s): %v (continuing)", uid, err)
}
}
}
func setLastAction(t *testing.T, uid id.UserID, at time.Time) {
t.Helper()
if _, err := db.Get().Exec(
`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
at, string(uid)); err != nil {
t.Fatalf("setLastAction(%s): %v", uid, err)
}
}
// gearFingerprint is what the bored run must not change.
func gearFingerprint(t *testing.T, uid id.UserID) string {
t.Helper()
eq, err := loadAdvEquipment(uid)
if err != nil {
return "ERR:" + err.Error()
}
var parts []string
for slot, e := range eq {
if e == nil {
continue
}
parts = append(parts, fmt.Sprintf("%s=%s/T%d", slot, e.Name, e.Tier))
}
sort.Strings(parts)
return fmt.Sprint(parts)
}
func expeditionIsBoredom(t *testing.T, expID string) bool {
t.Helper()
var b bool
if err := db.Get().QueryRow(
`SELECT boredom FROM dnd_expedition WHERE expedition_id = ?`, expID).Scan(&b); err != nil {
t.Logf(" expeditionIsBoredom(%s): %v", expID, err)
return false
}
return b
}
func activeExpeditionOwners(t *testing.T) map[id.UserID]bool {
t.Helper()
out := map[id.UserID]bool{}
rows, err := db.Get().Query(`SELECT user_id FROM dnd_expedition WHERE status = 'active'`)
if err != nil {
t.Fatalf("activeExpeditionOwners: %v", err)
}
defer rows.Close()
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatal(err)
}
out[id.UserID(s)] = true
}
return out
}
// whyNoBoredomRun re-runs tryBoredomStart's guards for a candidate who didn't
// leave, so the candidates-vs-departures gap is explained rather than assumed.
func whyNoBoredomRun(uid id.UserID) string {
c, err := LoadDnDCharacter(uid)
switch {
case err != nil:
return "character won't load: " + err.Error()
case c == nil:
return "no character"
case c.PendingSetup:
return "character creation never finished"
}
if restingLockoutRemaining(c) > 0 {
return "resting lockout"
}
if hasActiveCombatSession(uid) {
return "in combat"
}
if seated, _ := seatedExpeditionFor(uid); seated != nil {
return "seated on someone else's party"
}
if active, _ := getActiveExpedition(uid); active != nil {
return "already on an expedition"
}
if pending, _ := getResumableExpedition(uid); pending != nil {
return "extracted, waiting on !resume"
}
if run, _ := getActiveZoneRun(uid); run != nil {
return "mid zone run"
}
zone, ok := pickBoredomZone(uid, c.Level)
if !ok {
return fmt.Sprintf("no zone in band for L%d", c.Level)
}
return fmt.Sprintf("(no guard tripped — would have gone to %s; check cooldown/coins)", zone.ID)
}
func stopName(r stopReason) string {
switch r {
case stopOK:
return "ok"
case stopFork:
return "fork"
case stopElite:
return "elite doorway"
case stopBoss:
return "boss doorway"
case stopEnded:
return "died"
case stopComplete:
return "zone cleared"
case stopBlocked:
return "blocked by combat session"
case stopHarvestCombat:
return "harvest pulled a fight"
case stopPreflight:
return "preflight (low HP/SU)"
case stopBossSafety:
return "boss-safety gate"
}
return fmt.Sprintf("stopReason(%d)", int(r))
}
func diffOwners(after, before map[id.UserID]bool) []id.UserID {
var out []id.UserID
for uid := range after {
if !before[uid] {
out = append(out, uid)
}
}
return out
}

View File

@@ -0,0 +1,459 @@
package plugin
import (
"fmt"
"log/slog"
"sort"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Bored adventurers (gogobee_boredom_plan.md).
//
// A player who stops tending their adventurer doesn't stop having one. After
// boredomIdleThreshold with no action against Adventure, the character gets
// restless and leaves on an expedition by itself: the easiest zone its level
// band allows, the cheapest supplies it can afford, and whatever gear was
// already on the rack.
//
// Everything downstream of the start is already autonomous — the autopilot
// (expedition_autorun.go) walks rooms, drives elite and boss fights on the real
// turn engine, camps, harvests, rolls days over, and auto-picks forks after 8h.
// The only thing that ever needed a human was `!expedition start`, so that is
// the only thing this file does.
//
// It never buys or equips gear, and that is the entire mechanic: a neglected
// adventurer grinds half-starved runs on rusting equipment and comes home taxed.
const (
// boredomIdleThreshold — silence, measured against the player's last
// action *against Adventure*, before the adventurer leaves on its own.
boredomIdleThreshold = 24 * time.Hour
// boredomCooldown — minimum gap between boredom starts for one player.
// Also the re-trigger cadence: when a bored expedition ends, another
// boredomCooldown of silence starts the next one. An abandoned character
// keeps going until it runs out of coins for supplies.
boredomCooldown = 24 * time.Hour
// boredomTickInterval — how often the ticker wakes. The cooldown gate is
// what actually paces starts; the tick only has to be frequent enough to
// enforce it cleanly.
boredomTickInterval = 30 * time.Minute
)
// ── The idle clock ───────────────────────────────────────────────────────────
// markPlayerAction stamps player_meta.last_player_action_at — the clock the
// boredom ticker reads.
//
// It is a direct UPDATE and deliberately does NOT route through
// saveAdvCharacter. Every other candidate timestamp in the schema is unusable
// here for the same reason (gogobee_boredom_plan.md §1):
//
// - last_active_at is auto-bumped inside saveAdvCharacter, so the autopilot's
// own combat writes would refresh a bored character's idle clock and it
// would never qualify again.
// - user_stats.updated_at is bumped by any Matrix message in any room. That's
// chat presence, not a game action — a player can talk all day without
// touching their adventurer, and the adventurer should still get bored.
// - loadAdvDailyActivity is unified across dnd_expedition_log, so the
// autopilot's own walk entries read back as player activity.
//
// Any future non-Matrix entry point (web, API, Pete) should call this too — the
// clock tracks actions against Adventure, not Matrix traffic.
func markPlayerAction(userID id.UserID) {
db.Exec("boredom: mark player action",
`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
time.Now().UTC(), string(userID))
}
// advActionCommands — every `!command` routed by AdventurePlugin.OnMessage.
//
// This is NOT Commands() (adventure.go): that registers 28 names for the help
// surface and is missing expedition, zone, fight, camp, extract, resume and
// more. Routing is a 47-branch if-chain, and the boredom clock has to see all
// of it, so the set is spelled out here. TestAdvActionCommandsCoverDispatch
// greps the IsCommand literals out of adventure.go and fails if one is missing,
// so a new command can't silently fall out of the clock.
var advActionCommands = map[string]bool{
"abilities": true, "adv": true, "adventure": true, "arena": true,
"arm": true, "attack": true, "bail": true, "camp": true,
"cast": true, "check": true, "commune": true, "consume": true,
"craft": true, "duel": true, "essence": true, "expedition": true,
"explore": true, "extract": true, "fight": true, "fish": true,
"flee": true, "forage": true, "give": true, "graveyard": true,
"hospital": true, "level": true, "lore": true, "map": true,
"mine": true, "mischief": true, "news": true, "prepare": true, "region": true,
"resources": true, "respec": true, "rest": true, "resume": true,
"revisit": true, "rivals": true, "roll": true, "scavenge": true,
"sell": true, "setup": true, "sheet": true, "spells": true,
"stats": true, "subclass": true, "thom": true, "threat": true,
"town": true, "zone": true,
}
// isPlayerAdventureAction reports whether this message is the player doing
// something to their adventurer — an Adventure command, or a reply to a prompt
// we're holding open for them (shop, blacksmith, pet, treasure). Both count:
// answering "2" to a shop menu is as much an action as typing `!shop`.
//
// This runs on every message the bot sees, in every room, so the command test
// is a single tokenise-and-look-up rather than 50 passes of IsCommand over the
// body. It matches util.IsCommand's rule exactly: trimmed, lowercased, the
// command is the whole body or is followed by a space.
//
// The pending-prompt check honours ExpiresAt. Nothing sweeps p.pending, so an
// abandoned prompt (offered, never answered) sits there forever — without the
// expiry test, every idle remark that player ever makes in any room would read
// as tending their adventurer, and the clock would never run down.
func (p *AdventurePlugin) isPlayerAdventureAction(ctx MessageContext) bool {
body := strings.ToLower(strings.TrimSpace(ctx.Body))
if strings.HasPrefix(body, p.Prefix) {
word := strings.TrimPrefix(body, p.Prefix)
if i := strings.IndexByte(word, ' '); i >= 0 {
word = word[:i]
}
if advActionCommands[word] {
return true
}
}
if v, ok := p.pending.Load(string(ctx.Sender)); ok {
pi, isPrompt := v.(*advPendingInteraction)
// A zero ExpiresAt is a prompt with no deadline, not an expired one.
if isPrompt && (pi.ExpiresAt.IsZero() || time.Now().Before(pi.ExpiresAt)) {
return true
}
}
return false
}
// ── The ticker ───────────────────────────────────────────────────────────────
func (p *AdventurePlugin) expeditionBoredomTicker() {
ticker := time.NewTicker(boredomTickInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.fireExpeditionBoredom(time.Now().UTC())
}
}
}
// fireExpeditionBoredom sends every eligible idle adventurer into a dungeon.
func (p *AdventurePlugin) fireExpeditionBoredom(now time.Time) {
// Same politeness gate as the ambient ticker: don't drop a departure DM on
// top of the 06:00 briefing or 21:00 recap.
if inAmbientQuietWindow(now) {
return
}
uids, err := loadBoredomCandidates(now)
if err != nil {
slog.Error("boredom: load candidates", "err", err)
return
}
for _, uid := range uids {
if err := p.tryBoredomStart(uid, now); err != nil {
slog.Warn("boredom: start failed", "user", uid, "err", err)
}
}
}
// loadBoredomCandidates returns players whose last action against Adventure is
// older than boredomIdleThreshold and who haven't been sent out inside the
// cooldown.
//
// COALESCE onto created_at so a character that was made and then never played
// still qualifies — but not on its very first tick, since created_at is the
// clock until they touch anything.
func loadBoredomCandidates(now time.Time) ([]id.UserID, error) {
idleCutoff := now.Add(-boredomIdleThreshold)
coolCutoff := now.Add(-boredomCooldown)
rows, err := db.Get().Query(`
SELECT user_id
FROM player_meta
WHERE alive = 1
AND COALESCE(last_player_action_at, created_at) IS NOT NULL
AND COALESCE(last_player_action_at, created_at) < ?
AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
idleCutoff, coolCutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []id.UserID
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, id.UserID(s))
}
return out, rows.Err()
}
// tryBoredomStart runs the eligibility guards for one player and, if they pass,
// outfits and launches the expedition.
func (p *AdventurePlugin) tryBoredomStart(uid id.UserID, now time.Time) error {
// The foreground commands mutate the same rows under this lock; a boredom
// start racing `!expedition start` would double-book the character.
userMu := p.advUserLock(uid)
userMu.Lock()
defer userMu.Unlock()
c, err := LoadDnDCharacter(uid)
if err != nil {
return err
}
if c == nil || c.PendingSetup {
return nil // never finished character creation — nothing to get bored
}
// Structural blockers. Every one of these is a state the player has to
// resolve themselves; the adventurer can't just walk out of it. Mirrors the
// guard set in expeditionCmdStart.
if restingLockoutRemaining(c) > 0 {
return nil
}
if hasActiveCombatSession(uid) {
return nil
}
if seated, _ := seatedExpeditionFor(uid); seated != nil {
return nil // riding someone else's party
}
if active, _ := getActiveExpedition(uid); active != nil {
return nil
}
if pending, _ := getResumableExpedition(uid); pending != nil {
return nil // extracted, holding a roster for `!resume` — theirs to settle
}
if run, _ := getActiveZoneRun(uid); run != nil {
return nil
}
zone, ok := pickBoredomZone(uid, c.Level)
if !ok {
return nil // no zone this character's level band can be sent to
}
// Claim the cooldown BEFORE spending anything. If the start then fails, or
// the process dies mid-flight, we wait out the cooldown instead of looping
// on a broken character every 30 minutes.
res, err := db.Get().Exec(`
UPDATE player_meta
SET last_boredom_at = ?
WHERE user_id = ?
AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
now, string(uid), now.Add(-boredomCooldown))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil // another tick claimed it
}
// Cheapest pack that exists. A zero-supply start is impossible —
// SupplyPurchase.Validate rejects it — so a bored run always costs coins,
// and a broke adventurer simply doesn't go. That's the floor that stops an
// abandoned character looping forever.
purchase := loadoutPurchase(zone.Tier, LoadoutLean)
cost := float64(purchase.Cost())
if p.euro == nil {
return nil
}
if balance := p.euro.GetBalance(uid); balance < cost {
return p.SendDM(uid, fmt.Sprintf(
"🪙 I got as far as the supply counter. Outfitting for **%s** runs **%d** coins and we have **%.0f**. So I've put the pack back and come home. Nothing to be done about it from where I'm standing.",
zone.Display, int(cost), balance))
}
exp, supplies, _, err := p.beginExpedition(uid, c.Level, zone, purchase, "expedition outfitting (restless)")
if err != nil {
return err
}
// Mark it as self-started. The idle reaper reads this so an expedition the
// player never asked for can't hold their streak or spare them the shame DM
// (gogobee_boredom_plan.md §6).
db.Exec("boredom: flag expedition",
`UPDATE dnd_expedition SET boredom = 1 WHERE expedition_id = ?`, exp.ID)
slog.Info("boredom: adventurer left on its own",
"user", uid, "zone", zone.ID, "level", c.Level, "cost", int(cost))
emitBoredomDeparture(uid, zone, c.Level)
return p.SendDM(uid, renderBoredomDepartureDM(zone, supplies, purchase))
}
// ── Reading the idle clock ───────────────────────────────────────────────────
// playerIsIdle reports whether the player has gone boredomIdleThreshold without
// an action against Adventure. A player with no recorded action at all (never
// played, or predates the column) counts as idle.
// The COALESCE is done in Go, not SQL, on purpose. modernc.org/sqlite rebuilds
// a time.Time from the column's declared type, and COALESCE() erases it — the
// value comes back a string and the Scan fails. Selecting the two declared
// DATETIME columns and folding them here keeps the affinity. This function
// fails open (an unreadable row reads as idle), so a broken scan here would
// declare every player idle and march the whole server into a dungeon at once.
func playerIsIdle(userID id.UserID, now time.Time) bool {
var lastAction, created *time.Time
err := db.Get().QueryRow(
`SELECT last_player_action_at, created_at FROM player_meta WHERE user_id = ?`,
string(userID)).Scan(&lastAction, &created)
if err != nil {
return true
}
last := lastAction
if last == nil {
last = created // never acted — the clock runs from when they were made
}
if last == nil {
return true
}
return last.Before(now.Add(-boredomIdleThreshold))
}
// isBoredomDriven reports that this character is running on its own and its
// player has not come back: the last expedition they set out on was one the
// adventurer started for itself, and they still haven't touched Adventure.
//
// This is the gate the idle reaper uses. It's deliberately narrower than "is
// the player idle": an expedition the player *did* start still holds their
// streak while the autopilot walks it, exactly as it always has. Only an
// expedition nobody asked for loses that protection.
//
// Reading the most recent expedition rather than the active one keeps it honest
// for the night after a bored run ends — the logs it left behind would
// otherwise read as player activity for another day.
func isBoredomDriven(userID id.UserID, now time.Time) bool {
if !playerIsIdle(userID, now) {
return false // they came back; nothing to reap
}
var boredom bool
err := db.Get().QueryRow(`
SELECT boredom
FROM dnd_expedition
WHERE user_id = ?
ORDER BY start_date DESC
LIMIT 1`, string(userID)).Scan(&boredom)
if err != nil {
return false // no expedition history, or unreadable — don't reap on a guess
}
return boredom
}
// ── Zone selection ───────────────────────────────────────────────────────────
// pickBoredomZone chooses where a restless adventurer goes.
//
// It uses the LevelMin/LevelMax bands on ZoneDefinition rather than
// zonesForLevel, which filters on tier alone and ignores those fields — which
// is why a level-1 player can currently walk into a T3 zone. A bored adventurer
// should neither farm trivial content nor wander somewhere absurd, so the band
// is the right gate.
//
// Order of preference:
// 1. Zones whose level band contains the character.
// 2. Among those, the non-raid ones (raidContentWarning) — the party-tuned T5
// bosses have a 0% solo clear rate, so they're avoided while an alternative
// exists.
// 3. Among those, the lowest tier: the "easiest at-level" rule.
// 4. Ties break on least-recently-visited, so a bored adventurer rotates zones
// instead of grinding one forever.
//
// If step 2 leaves nothing, raid zones are allowed back in. That only bites at
// L13+, where dragons_lair and abyss_portal are the only zones in band and both
// are raids. Those runs cannot be won solo — they wall at the boss-safety gate,
// starve, and force-extract with the haul tax. That is the intended fate of an
// abandoned endgame character, and it drains the coins that fund the next one.
func pickBoredomZone(uid id.UserID, level int) (ZoneDefinition, bool) {
var inBand, nonRaid []ZoneDefinition
for _, z := range allZones() {
if level < z.LevelMin || level > z.LevelMax {
continue
}
inBand = append(inBand, z)
if raidContentWarning(z.ID) == "" {
nonRaid = append(nonRaid, z)
}
}
pool := nonRaid
if len(pool) == 0 {
pool = inBand
}
if len(pool) == 0 {
return ZoneDefinition{}, false
}
lastVisit, err := lastExpeditionByZone(uid)
if err != nil {
slog.Warn("boredom: zone history unavailable, falling back to tier order", "user", uid, "err", err)
lastVisit = map[ZoneID]time.Time{}
}
sort.SliceStable(pool, func(i, j int) bool {
if pool[i].Tier != pool[j].Tier {
return pool[i].Tier < pool[j].Tier
}
return lastVisit[pool[i].ID].Before(lastVisit[pool[j].ID])
})
return pool[0], true
}
// lastExpeditionByZone returns when this player last set out for each zone.
// Zones they've never visited are absent, so they sort first (zero time).
//
// No MAX(start_date)/GROUP BY: the aggregate erases the column's declared type
// and modernc.org/sqlite hands back a string the Scan can't take. Walking the
// rows in ascending order and letting each overwrite the last gives the same
// answer with the affinity intact.
func lastExpeditionByZone(uid id.UserID) (map[ZoneID]time.Time, error) {
rows, err := db.Get().Query(`
SELECT zone_id, start_date
FROM dnd_expedition
WHERE user_id = ?
ORDER BY start_date ASC`, string(uid))
if err != nil {
return nil, err
}
defer rows.Close()
out := map[ZoneID]time.Time{}
for rows.Next() {
var zone string
var last time.Time
if err := rows.Scan(&zone, &last); err != nil {
return nil, err
}
out[ZoneID(zone)] = last // ascending, so the last write is the most recent
}
return out, rows.Err()
}
// ── Rendering ────────────────────────────────────────────────────────────────
// renderBoredomDepartureDM is the note left on the table. It states plainly
// that nothing was bought and nothing was upgraded — the player should be able
// to read this and know exactly why the haul is going to be bad.
func renderBoredomDepartureDM(zone ZoneDefinition, supplies ExpeditionSupplies, purchase SupplyPurchase) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("🎒 **Gone on ahead — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
b.WriteString("_" + zone.Hook + "_\n\n")
b.WriteString(flavor.Pick(flavor.ExpeditionBoredomStart))
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("**Packed:** %.0f SU (%d standard) — %d coins, the cheapest that would get us through the gate\n",
supplies.Max, purchase.StandardPacks, purchase.Cost()))
b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — roughly **%d days** before we're rationing\n",
supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn)))
b.WriteString("**Equipment:** unchanged. I don't do the shopping.\n\n")
if w := raidContentWarning(zone.ID); w != "" {
b.WriteString(w)
b.WriteString("\n\n")
}
b.WriteString("`!expedition status` to see where we've got to. Come and find us.")
return b.String()
}

View File

@@ -0,0 +1,261 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// These exercise the idle clock against real rows.
//
// The hazard they exist for: modernc.org/sqlite rebuilds a time.Time from the
// column's *declared* type, and both COALESCE() and MAX() erase it. A Scan that
// silently fails here is not a small bug — playerIsIdle fails open (a player it
// can't read counts as idle), so a broken scan would declare the entire server
// idle and march everyone into a dungeon at once.
func seedBoredomPlayer(t *testing.T, uid id.UserID, created, lastAction, lastBoredom *time.Time) {
t.Helper()
_, err := db.Get().Exec(
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at, last_boredom_at)
VALUES (?, ?, 1, ?, ?, ?)`,
string(uid), "Test", created, lastAction, lastBoredom)
if err != nil {
t.Fatalf("seed player_meta: %v", err)
}
}
func TestPlayerIsIdleReadsTheClock(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-72 * time.Hour)
recent := now.Add(-1 * time.Hour)
seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
// Never acted: the COALESCE falls through to created_at.
seedBoredomPlayer(t, "@never:test", &old, nil, nil)
seedBoredomPlayer(t, "@fresh:test", &recent, nil, nil)
cases := []struct {
uid id.UserID
want bool
why string
}{
{"@idle:test", true, "last acted 72h ago"},
{"@active:test", false, "acted an hour ago"},
{"@never:test", true, "never acted, created 72h ago — COALESCE to created_at"},
{"@fresh:test", false, "never acted, but only just created — must not be yanked out on day one"},
}
for _, tc := range cases {
if got := playerIsIdle(tc.uid, now); got != tc.want {
t.Errorf("playerIsIdle(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
}
}
}
func TestLoadBoredomCandidatesRespectsClockAndCooldown(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-72 * time.Hour)
recent := now.Add(-1 * time.Hour)
seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
// Idle, but already sent out an hour ago — the cooldown holds them back.
seedBoredomPlayer(t, "@cooling:test", &old, &old, &recent)
// Idle, and their last outing was days ago — eligible again.
seedBoredomPlayer(t, "@ready:test", &old, &old, &old)
got, err := loadBoredomCandidates(now)
if err != nil {
t.Fatalf("loadBoredomCandidates: %v", err)
}
set := map[id.UserID]bool{}
for _, u := range got {
set[u] = true
}
for uid, want := range map[id.UserID]bool{
"@idle:test": true,
"@ready:test": true,
"@active:test": false,
"@cooling:test": false,
} {
if set[uid] != want {
t.Errorf("candidate %s: got %v, want %v", uid, set[uid], want)
}
}
}
func TestMarkPlayerActionResetsTheClock(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-72 * time.Hour)
seedBoredomPlayer(t, "@back:test", &old, &old, nil)
if !playerIsIdle("@back:test", now) {
t.Fatal("precondition: player should start idle")
}
markPlayerAction("@back:test")
if playerIsIdle("@back:test", now) {
t.Error("player acted against Adventure but is still counted idle — the clock isn't being stamped")
}
}
// TestIsBoredomDrivenNeedsBothHalves pins the rule the idle reaper depends on:
// only an expedition the player never asked for, from a player who still hasn't
// come back, loses its streak protection. A self-started expedition holds it.
func TestIsBoredomDrivenNeedsBothHalves(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-72 * time.Hour)
recent := now.Add(-1 * time.Hour)
seedExp := func(t *testing.T, uid id.UserID, boredom bool) {
t.Helper()
_, err := db.Get().Exec(
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date, boredom)
VALUES (?, ?, 'goblin_warrens', 'active', ?, ?)`,
string(uid)+"-exp", string(uid), old, boredom)
if err != nil {
t.Fatalf("seed expedition: %v", err)
}
}
seedBoredomPlayer(t, "@bored:test", &old, &old, nil)
seedExp(t, "@bored:test", true)
seedBoredomPlayer(t, "@selfstart:test", &old, &old, nil)
seedExp(t, "@selfstart:test", false)
seedBoredomPlayer(t, "@returned:test", &old, &recent, nil)
seedExp(t, "@returned:test", true)
seedBoredomPlayer(t, "@noexp:test", &old, &old, nil)
cases := []struct {
uid id.UserID
want bool
why string
}{
{"@bored:test", true, "absent player, expedition it started itself — reap"},
{"@selfstart:test", false, "absent, but they started this expedition themselves — the autopilot still holds their streak"},
{"@returned:test", false, "bored expedition, but the player came back — don't reap someone who's playing"},
{"@noexp:test", false, "no expedition history at all — don't reap on a guess"},
}
for _, tc := range cases {
if got := isBoredomDriven(tc.uid, now); got != tc.want {
t.Errorf("isBoredomDriven(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
}
}
}
// TestLastExpeditionByZoneScans covers the MAX(start_date) scan — the same
// type-affinity hazard as the COALESCE above, and the tie-breaker that stops a
// bored adventurer grinding one zone forever.
func TestLastExpeditionByZoneScans(t *testing.T) {
newBoredomTestDB(t)
uid := id.UserID("@hist:test")
older := time.Now().UTC().Add(-96 * time.Hour)
newer := time.Now().UTC().Add(-24 * time.Hour)
for i, e := range []struct {
zone string
when time.Time
}{
{"goblin_warrens", older},
{"goblin_warrens", newer},
{"crypt_valdris", older},
} {
if _, err := db.Get().Exec(
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date)
VALUES (?, ?, ?, 'complete', ?)`,
string(uid)+string(rune('a'+i)), string(uid), e.zone, e.when); err != nil {
t.Fatalf("seed: %v", err)
}
}
got, err := lastExpeditionByZone(uid)
if err != nil {
t.Fatalf("lastExpeditionByZone: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d zones, want 2 — the MAX(start_date) scan is dropping rows: %v", len(got), got)
}
if !got[ZoneGoblinWarrens].After(got[ZoneCryptValdris]) {
t.Errorf("goblin_warrens (%v) should be more recent than crypt_valdris (%v); "+
"the picker relies on this to rotate zones", got[ZoneGoblinWarrens], got[ZoneCryptValdris])
}
}
// The deploy this column ships in: last_player_action_at is NULL for everyone
// alive, and created_at is when the character was *made* — months ago for the
// regulars. Without a seed, the very first tick after restart reads the whole
// server as idle-since-creation and marches all of them into a dungeon, coins
// debited, including the player who typed a command a minute before the deploy.
func TestBootstrapSeedsClockSoADeployDoesNotEmptyTheTown(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
longAgo := now.Add(-90 * 24 * time.Hour)
recent := now.Add(-30 * time.Minute)
// A regular: made months ago, played half an hour before the deploy.
seedBoredomPlayerActive(t, "@regular:test", longAgo, &recent, nil)
// Genuinely abandoned: made months ago, last touched the game months ago.
seedBoredomPlayerActive(t, "@lapsed:test", longAgo, &longAgo, nil)
bootstrapBoredomClock()
uids, err := loadBoredomCandidates(now)
if err != nil {
t.Fatalf("loadBoredomCandidates: %v", err)
}
got := map[id.UserID]bool{}
for _, u := range uids {
got[u] = true
}
if got["@regular:test"] {
t.Error("a player who acted 30 minutes ago was sent into a dungeon by the deploy")
}
if !got["@lapsed:test"] {
t.Error("the abandoned character is exactly who this feature is for, and it skipped them")
}
if playerIsIdle("@regular:test", now) {
t.Error("playerIsIdle still reads an active player as idle — Robbie would go silent for them")
}
}
// Same seed, but the character is already out on a bored expedition
// (last_boredom_at set). Its own autopilot bumps last_active_at every day, so
// re-seeding from it on each restart would hand the character a fresh idle
// clock and boredom would never fire again.
func TestBootstrapLeavesAlreadyBoredCharactersAlone(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
longAgo := now.Add(-90 * 24 * time.Hour)
autopilotWrite := now.Add(-10 * time.Minute)
wentOut := now.Add(-48 * time.Hour)
seedBoredomPlayerActive(t, "@bored:test", longAgo, &autopilotWrite, &wentOut)
bootstrapBoredomClock()
if !playerIsIdle("@bored:test", now) {
t.Fatal("restart re-seeded a bored character's clock off its own autopilot writes; it would never get bored again")
}
}
// seedBoredomPlayerActive seeds a row that predates the boredom column:
// last_player_action_at NULL, with a last_active_at the autopilot/saves have
// been keeping current.
func seedBoredomPlayerActive(t *testing.T, uid id.UserID, created time.Time, lastActive, lastBoredom *time.Time) {
t.Helper()
_, err := db.Get().Exec(
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_active_at, last_player_action_at, last_boredom_at)
VALUES (?, ?, 1, ?, ?, NULL, ?)`,
string(uid), "Test", created, lastActive, lastBoredom)
if err != nil {
t.Fatalf("seed player_meta: %v", err)
}
}

View File

@@ -0,0 +1,95 @@
package plugin
import (
"os"
"regexp"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// pickBoredomZone reads expedition history to break ties, so it needs a DB.
func newBoredomTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// TestAdvActionCommandsCoverDispatch pins the boredom idle clock to the actual
// command router.
//
// advActionCommands has to list every command AdventurePlugin.OnMessage routes,
// because a command missing from the set is a command the player can use
// without their adventurer noticing they showed up — so it sits there idle,
// gets "bored", and walks into a dungeon while they're actively playing.
//
// Commands() can't be used as the source of truth: it registers 28 names for
// the help surface and omits expedition, zone, fight, camp, extract and resume.
// So the router is the source of truth, and this greps it.
func TestAdvActionCommandsCoverDispatch(t *testing.T) {
src, err := os.ReadFile("adventure.go")
if err != nil {
t.Fatalf("read adventure.go: %v", err)
}
re := regexp.MustCompile(`p\.IsCommand\(ctx\.Body, "([a-z0-9-]+)"\)`)
matches := re.FindAllSubmatch(src, -1)
if len(matches) < 40 {
t.Fatalf("only found %d IsCommand call sites in adventure.go — the regex has "+
"drifted from the dispatcher and this test is no longer guarding anything", len(matches))
}
for _, m := range matches {
name := string(m[1])
if !advActionCommands[name] {
t.Errorf("!%s is routed by OnMessage but missing from advActionCommands — "+
"players using it would not reset their boredom clock", name)
}
}
}
// TestPickBoredomZoneBands walks the level bands and asserts the picker's
// rules: easiest at-level, no raid zones while an alternative exists, and the
// L13+ fallback into the raid.
func TestPickBoredomZoneBands(t *testing.T) {
newBoredomTestDB(t)
cases := []struct {
level int
want ZoneID
why string
}{
{level: 1, want: ZoneGoblinWarrens, why: "T1 band, tie broken by never-visited order"},
{level: 5, want: ZoneForestShadows, why: "L5 is in both the T2 and T3 bands — take the easier"},
{level: 10, want: ZoneUnderdark, why: "T4 band; underdark is multi-region but NOT a raid, so it stays eligible. Both T4 zones are unvisited here, so the tie falls through to registration order"},
{level: 15, want: ZoneDragonsLair, why: "only raid zones in band at L15 — fallback fires, lowest tier first"},
}
for _, tc := range cases {
got, ok := pickBoredomZone(id.UserID("@nobody:test"), tc.level)
if !ok {
t.Errorf("L%d: no zone picked (%s)", tc.level, tc.why)
continue
}
if got.ID != tc.want {
t.Errorf("L%d: picked %s, want %s (%s)", tc.level, got.ID, tc.want, tc.why)
}
}
}
// TestPickBoredomZoneAvoidsRaidsWhenPossible is the rule the player actually
// asked for, stated directly: below the endgame, a bored adventurer never gets
// sent into party-tuned content.
func TestPickBoredomZoneAvoidsRaidsWhenPossible(t *testing.T) {
newBoredomTestDB(t)
for level := 1; level <= 12; level++ {
z, ok := pickBoredomZone(id.UserID("@nobody:test"), level)
if !ok {
continue
}
if raidContentWarning(z.ID) != "" {
t.Errorf("L%d: picked raid zone %s while a non-raid zone was in band", level, z.ID)
}
}
}

View File

@@ -0,0 +1,85 @@
package plugin
// M0 pricing sweep for gogobee_mischief_plan.md — single-fight survival per
// mischief tier, measured against the class-balance harness builds. Skip-gated:
// it is a pricing instrument, not a regression test.
//
// It drives the SAME production selection code a live delivery does
// (mischiefBracketZone + mischiefMonsters), so the fee table can't silently
// drift away from the fight it was priced against. What it does NOT share is the
// delivery path itself: this measures a full-HP build in a single chain, which
// is optimistic — a real target is wounded mid-run, and may have a party. The
// plan's M1 close-out item is to re-run this through runMischiefInterrupt.
//
// Run: MISCHIEF_SWEEP=1 go test ./internal/plugin -run TestMischiefPricingSweep -v
import (
"fmt"
"math/rand/v2"
"os"
"testing"
)
// one trial: full-HP build fights the tier's chain; survival = won every fight.
func mischiefTrial(p classBalanceProfile, tierKey string, rng *rand.Rand) (survived bool, endHPPct float64) {
c := buildHarnessCharacter(p)
zone := mischiefBracketZone(p.Level)
chain, ambush := mischiefMonsters(tierKey, zone, rng)
hp := c.HPMax
for i, m := range chain {
if ambush && i == 0 {
hp -= clampSurpriseNick(surpriseRoundNick(m, int(zone.Tier)), hp, c.HPMax)
}
player := buildHarnessPlayer(c)
if hp < c.HPMax {
player.Stats.StartHP = hp
}
enemy := buildHarnessZoneEnemy(m, int(zone.Tier))
if spell, slot, ok := pickBestDamageSpell(c); ok {
applyHarnessSpellCast(c, spell, slot, &player.Stats, &player.Mods, &enemy.Stats)
}
res := SimulateCombat(player, enemy, dungeonCombatPhases)
if !res.PlayerWon {
return false, 0
}
hp = res.PlayerEndHP
}
return true, float64(hp) / float64(c.HPMax)
}
func TestMischiefPricingSweep(t *testing.T) {
if os.Getenv("MISCHIEF_SWEEP") == "" {
t.Skip("set MISCHIEF_SWEEP=1 to run")
}
const trials = 2000
rng := rand.New(rand.NewPCG(20260713, 0x6D69736368696566))
profiles := []classBalanceProfile{
{Class: ClassPaladin, Level: 1},
{Class: ClassMage, Level: 4},
{Class: ClassMage, Level: 8, Subclass: SubclassEvocation},
{Class: ClassFighter, Level: 12, Subclass: SubclassChampion},
{Class: ClassRogue, Level: 20, Subclass: SubclassArcaneTrickster},
{Class: ClassCleric, Level: 20, Subclass: SubclassLifeDomain},
}
fmt.Printf("%-28s %-8s %8s %10s\n", "profile", "tier", "survive%", "avgEndHP%")
for _, p := range profiles {
for _, tier := range mischiefTiers {
wins := 0
hpSum := 0.0
for i := 0; i < trials; i++ {
ok, hpPct := mischiefTrial(p, tier.Key, rng)
if ok {
wins++
hpSum += hpPct
}
}
avgHP := 0.0
if wins > 0 {
avgHP = hpSum / float64(wins) * 100
}
fmt.Printf("%-28s %-8s %7.1f%% %9.1f%%\n",
fmt.Sprintf("%s L%d %s", p.Class, p.Level, p.Subclass), tier.Key,
float64(wins)/trials*100, avgHP)
}
}
}

View File

@@ -258,10 +258,45 @@ func claimRealmFirst(kind, target string) bool {
}
// emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The
// realm's first clear of a zone is a PRIORITY "zone_first"; later clears are a
// BULLETIN "zone_clear". The event_type and the GUID prefix track the tier so
// Pete templates and labels a repeat as a repeat, not a first-ever. Character
// name only; no-op unless the seam is enabled.
// realm's first clear of a zone is a "zone_first"; later clears are a
// "zone_clear". The event_type and the GUID prefix track which, so Pete
// templates and labels a repeat as a repeat, not a first-ever. Character name
// only; no-op unless the seam is enabled.
//
// BULLETIN either way: TwinBee announces the clear in the room as it happens,
// and priority tier is what makes Pete post a live beat — so a priority
// zone_first would just echo TwinBee back at the same people. Bulletin still
// gets the story onto the site and into the daily digest, where it reads as a
// roundup rather than a repeat.
// emitBoredomDeparture announces that an adventurer got restless and let itself
// out. The one thing gogobee never used to tell Pete was that an expedition
// *started* — every dispatch was an outcome — which is why the two live boredom
// runs produced no news at all.
//
// The event_type must be one Pete already knows: an unknown type is a 400, which
// retries and then parks the bulletin forever. Deploy Pete first.
func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) {
if !peteclient.Enabled() || !newsEmissionOn() {
return
}
name := charName(userID)
if name == "" {
return
}
ts := nowUnix()
disc := fmt.Sprintf("departure:%s:%d", zone.ID, ts)
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("departure:%s:%s:%d", eventToken(userID, disc), zone.ID, ts),
EventType: "departure",
Tier: "bulletin",
Subject: name,
Zone: zone.Display,
Level: level,
Outcome: "departed",
OccurredAt: ts,
}, userID, "")
}
func emitZoneClearNews(userID id.UserID, exp *Expedition) {
if !peteclient.Enabled() || !newsEmissionOn() {
return
@@ -270,9 +305,9 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
// genuine first clear still seeds news_realm_firsts. Otherwise the next
// named clearer would claim it and be mis-announced as the first-ever.
// Mirrors backfillZoneFirsts, which claims before its own name check.
eventType, tier := "zone_clear", "bulletin"
eventType := "zone_clear"
if claimRealmFirst("zone", string(exp.ZoneID)) {
eventType, tier = "zone_first", "priority"
eventType = "zone_first"
}
name := charName(userID)
if name == "" {
@@ -291,7 +326,7 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("%s:%s:%s:%d", eventType, eventToken(userID, disc), exp.ZoneID, ts),
EventType: eventType,
Tier: tier,
Tier: "bulletin",
Subject: name,
Zone: zone.Display,
Region: region,

View File

@@ -0,0 +1,177 @@
package plugin
import (
"context"
"log/slog"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// The live adventurer board pushed to Pete (gogobee_boredom_plan.md's sibling —
// see the roster section of the Pete plan).
//
// Everything else we send Pete is an accomplishment: a death, a clear, a
// milestone. Those are clippings — they read as archive the moment they land, no
// matter how fast we deliver them. The board is the other kind of thing: state
// that is *currently true*, which is the only thing that can make a page feel
// alive. So it is a snapshot, pushed whole, replacing whatever Pete had.
//
// It is also, by design, a target list. The plan is to let people who aren't
// even playing hire assassins and mobs against adventurers who are out in the
// world right now — so the board carries a stable per-player token and real zone
// depth, not just a pretty display string, and it shows the zone *live* while
// they're still in it.
const (
// rosterTickInterval — how often we push. Pete's staleness window is several
// times this, so a missed push or two is invisible; a real outage isn't.
rosterTickInterval = 2 * time.Minute
// rosterPushTimeout — the push is dropped on failure, never retried (a stale
// snapshot is a lie, and the next tick carries the truth), so it must not be
// able to pile up.
rosterPushTimeout = 15 * time.Second
)
// peteRosterTicker pushes the board to Pete forever.
func (p *AdventurePlugin) peteRosterTicker() {
if !peteclient.Enabled() {
return
}
ticker := time.NewTicker(rosterTickInterval)
defer ticker.Stop()
for range ticker.C {
if !newsEmissionOn() {
continue // master switch off: the board goes stale on Pete and says so
}
p.pushRoster()
}
}
// rosterPushOK tracks the last push's outcome so we can log the transitions and
// nothing else. A push every 2 minutes forever is far too noisy to log at INFO,
// but total silence is worse: a ticker that is succeeding quietly looks exactly
// like one that never started, and that ambiguity already cost an operator a
// wrong-turn debug during the first deploy. So: say something the first time it
// works, say something when it breaks, say something when it recovers.
var rosterPushOK bool
func (p *AdventurePlugin) pushRoster() {
snap, err := buildRosterSnapshot(time.Now().UTC())
if err != nil {
slog.Error("roster: build snapshot failed", "err", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
defer cancel()
if err := peteclient.PushRoster(ctx, snap); err != nil {
// The failure itself is not alarming — the next tick retries by simply
// being a fresher snapshot, and if we stay down Pete's board correctly
// stops claiming to be live. Only the *transition* is worth a line.
if rosterPushOK {
slog.Warn("roster: push failed, board will go stale on Pete", "err", err)
} else {
slog.Debug("roster: push failed, dropping snapshot", "err", err)
}
rosterPushOK = false
return
}
if !rosterPushOK {
slog.Info("roster: board accepted by Pete — live adventurer board is publishing",
"adventurers", len(snap.Adventurers))
rosterPushOK = true
}
}
// buildRosterSnapshot assembles the complete board.
//
// Complete is the contract: Pete *replaces* its board with this, so anyone we
// omit drops off the public page. That is exactly how the opt-out is enforced —
// an opted-out player is simply never in the payload, rather than being sent and
// anonymized. A standing row showing class + level + zone is trivially
// re-identifiable (there is one level-14 cleric), so "an adventurer" would have
// been a fig leaf; absence is the only honest option.
func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix()}
// Both DATETIME columns selected raw and folded in Go — NOT COALESCE()'d in
// SQL. modernc.org/sqlite rebuilds a time.Time from the column's *declared*
// type, and COALESCE() erases that affinity: the value comes back a string
// and the Scan fails. Same trap playerIsIdle documents.
rows, err := db.Get().Query(`
SELECT user_id, last_player_action_at, created_at
FROM player_meta
WHERE alive = 1`)
if err != nil {
return snap, err
}
defer rows.Close()
type player struct {
uid id.UserID
lastAction *time.Time
}
var players []player
for rows.Next() {
var uid string
var lastAction, created *time.Time
if err := rows.Scan(&uid, &lastAction, &created); err != nil {
return snap, err
}
if lastAction == nil {
lastAction = created
}
players = append(players, player{id.UserID(uid), lastAction})
}
if err := rows.Err(); err != nil {
return snap, err
}
for _, pl := range players {
if isNewsOptedOut(pl.uid) {
continue
}
c, err := LoadDnDCharacter(pl.uid)
if err != nil || c == nil || c.PendingSetup {
continue // no character to show; a half-made one has no name yet
}
name := charName(pl.uid)
if name == "" {
continue // never fall back to a Matrix handle on a public page
}
e := peteclient.RosterEntry{
// Stable per-player board token: salted, so it can't be recomputed
// from the handle, and distinct from every event token, so the board
// doesn't become the key that links a player's dispatches together.
Token: eventToken(pl.uid, "roster"),
Name: name,
Level: c.Level,
ClassRace: classRaceLabel(c),
Status: "idle",
}
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
zone := zoneOrFallback(exp.ZoneID)
e.Status = "expedition"
e.Zone = zone.Display
e.Day = exp.CurrentDay
if IsMultiRegionZone(exp.ZoneID) {
if r, ok := CurrentRegion(exp); ok {
e.Region = r.Name
}
}
} else if pl.lastAction != nil {
if h := int(now.Sub(*pl.lastAction).Hours()); h > 0 {
e.IdleHours = h
}
}
snap.Adventurers = append(snap.Adventurers, e)
}
return snap, nil
}

View File

@@ -0,0 +1,127 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// seedRosterPlayer puts a real, playable character on the board: a player_meta
// row (which carries the idle clock and the display name) plus a confirmed
// dnd_character. Real rows on purpose — the snapshot query reads two DATETIME
// columns, and the modernc affinity trap only fires against actual stored
// values, never against a hand-built struct.
func seedRosterPlayer(t *testing.T, uid id.UserID, name string, created, lastAction *time.Time) {
t.Helper()
if _, err := db.Get().Exec(
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at)
VALUES (?, ?, 1, ?, ?)`,
string(uid), name, created, lastAction); err != nil {
t.Fatalf("seed player_meta: %v", err)
}
if err := SaveDnDCharacter(&DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 15, DEX: 14, CON: 13, INT: 12, WIS: 10, CHA: 8,
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
PendingSetup: false,
}); err != nil {
t.Fatalf("seed character: %v", err)
}
}
// TestRosterSnapshotReadsTheClock is the scan-affinity guard. The snapshot
// selects last_player_action_at and created_at as declared DATETIME columns and
// folds them in Go; a COALESCE() in the SQL would erase the affinity, the Scan
// would fail, and buildRosterSnapshot would return an error — publishing an
// empty board (every adventurer vanishes from the public page) rather than
// anything obviously broken.
func TestRosterSnapshotReadsTheClock(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-50 * time.Hour)
recent := now.Add(-3 * time.Hour)
seedRosterPlayer(t, "@quiet:test", "Quack", &old, &old)
seedRosterPlayer(t, "@recent:test", "Josie", &old, &recent)
// Never acted at all: the fold falls through to created_at.
seedRosterPlayer(t, "@never:test", "Camcast", &old, nil)
snap, err := buildRosterSnapshot(now)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
if len(snap.Adventurers) != 3 {
t.Fatalf("board has %d adventurers, want 3", len(snap.Adventurers))
}
if snap.SnapshotAt != now.Unix() {
t.Errorf("snapshot_at = %d, want %d", snap.SnapshotAt, now.Unix())
}
byName := map[string]int{}
for _, a := range snap.Adventurers {
byName[a.Name] = a.IdleHours
if a.Status != "idle" {
t.Errorf("%s status = %q, want idle (nobody is on an expedition)", a.Name, a.Status)
}
if a.Token == "" {
t.Errorf("%s has no board token", a.Name)
}
}
if got := byName["Quack"]; got != 50 {
t.Errorf("Quack idle hours = %d, want 50 — the clock didn't survive the scan", got)
}
if got := byName["Josie"]; got != 3 {
t.Errorf("Josie idle hours = %d, want 3", got)
}
if got := byName["Camcast"]; got != 50 {
t.Errorf("Camcast idle hours = %d, want 50 (fell through to created_at)", got)
}
}
// TestRosterOmitsOptedOut is the privacy contract. Pete *replaces* its whole
// board with this payload, so omission is what enforces the opt-out. Anonymizing
// instead would be a fig leaf: a standing row with class + level + zone names the
// player anyway.
func TestRosterOmitsOptedOut(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-30 * time.Hour)
seedRosterPlayer(t, "@shown:test", "Josie", &old, &old)
seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old)
setNewsOptout("@hidden:test", true)
snap, err := buildRosterSnapshot(now)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
if len(snap.Adventurers) != 1 {
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
}
if snap.Adventurers[0].Name != "Quack" && snap.Adventurers[0].Name != "Josie" {
t.Fatalf("unexpected adventurer %q", snap.Adventurers[0].Name)
}
if snap.Adventurers[0].Name == "Quack" {
t.Error("an opted-out player is on the public board")
}
}
// TestRosterTokenIsNotAnEventToken: the board token must not be one of the
// player's event tokens, or a standing row would become the key that links all
// their dispatches back together — the exact unlinkability eventToken exists to
// provide.
func TestRosterTokenIsNotAnEventToken(t *testing.T) {
newBoredomTestDB(t)
uid := id.UserID("@josie:test")
board := eventToken(uid, "roster")
if board == eventToken(uid, "arrival") || board == eventToken(uid, "death:crypt:1") {
t.Error("board token collides with an event token — the board would deanonymize the feed")
}
if board != eventToken(uid, "roster") {
t.Error("board token not stable — the row would churn identity every snapshot")
}
}