mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
9 Commits
da398cf674
...
mischief-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94077a47ae | ||
|
|
8fc5a82b83 | ||
|
|
2ab6e7843a | ||
|
|
ced75786b9 | ||
|
|
1cbd68a718 | ||
|
|
e377e0c85c | ||
|
|
7c379b298c | ||
|
|
a533e106c2 | ||
|
|
2482433896 |
@@ -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
164
internal/bot/statestore.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/crypto"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// lazyStateStore resolves room state from the homeserver on first use instead of
|
||||
// waiting for it to arrive over a sync.
|
||||
//
|
||||
// The appservice has no /sync, so the state store only ever learns a room is
|
||||
// encrypted from an m.room.encryption event pushed in a transaction — which for
|
||||
// an already-configured room never happens again. Anything the bot says on its
|
||||
// own initiative (ambient DMs, expedition beats, briefings) therefore reached
|
||||
// SendMessageEvent with IsEncrypted=false and went out as plaintext into an
|
||||
// encrypted room. The state store being in-memory made every restart a fresh
|
||||
// chance to leak.
|
||||
//
|
||||
// Both reads the send path depends on are resolved here on the first miss and
|
||||
// cached: whether the room is encrypted, and who to share the group session with
|
||||
// (an empty member list encrypts to nobody, which is its own outage). Neither
|
||||
// read is allowed to fail open — a lookup that cannot be resolved returns an
|
||||
// error, which aborts the send. A failed message is recoverable; a plaintext one
|
||||
// that has already landed in an encrypted room is not.
|
||||
type lazyStateStore struct {
|
||||
// Embeds both interfaces the store is consumed through: the appservice
|
||||
// dispatches state updates through the first, and the crypto machine type-
|
||||
// asserts the client's store to the second (cryptohelper.NewCryptoHelper
|
||||
// rejects a store that fails the assertion). Embedding only the appservice
|
||||
// interface silently drops crypto's extra methods from the concrete type.
|
||||
innerStateStore
|
||||
|
||||
client *mautrix.Client
|
||||
|
||||
mu sync.Mutex
|
||||
locks map[id.RoomID]*sync.Mutex
|
||||
// Rooms whose m.room.encryption state we have asked the server about. The
|
||||
// underlying store cannot distinguish "not encrypted" from "never heard of
|
||||
// it", so the distinction is tracked here.
|
||||
encryptionResolved map[id.RoomID]bool
|
||||
}
|
||||
|
||||
// innerStateStore is the full method set the wrapped store must provide.
|
||||
type innerStateStore interface {
|
||||
appservice.StateStore
|
||||
crypto.StateStore
|
||||
}
|
||||
|
||||
// The wrapper is consumed through both, and losing either one is a startup
|
||||
// failure rather than a build failure without these.
|
||||
var (
|
||||
_ appservice.StateStore = (*lazyStateStore)(nil)
|
||||
_ crypto.StateStore = (*lazyStateStore)(nil)
|
||||
)
|
||||
|
||||
func newLazyStateStore(inner innerStateStore) *lazyStateStore {
|
||||
return &lazyStateStore{
|
||||
innerStateStore: inner,
|
||||
locks: make(map[id.RoomID]*sync.Mutex),
|
||||
encryptionResolved: make(map[id.RoomID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// lockRoom serialises resolution per room so a burst of sends into a cold room
|
||||
// makes one request rather than one per message.
|
||||
func (s *lazyStateStore) lockRoom(roomID id.RoomID) func() {
|
||||
s.mu.Lock()
|
||||
lock, ok := s.locks[roomID]
|
||||
if !ok {
|
||||
lock = &sync.Mutex{}
|
||||
s.locks[roomID] = lock
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
lock.Lock()
|
||||
return lock.Unlock
|
||||
}
|
||||
|
||||
func (s *lazyStateStore) isEncryptionResolved(roomID id.RoomID) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.encryptionResolved[roomID]
|
||||
}
|
||||
|
||||
func (s *lazyStateStore) markEncryptionResolved(roomID id.RoomID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.encryptionResolved[roomID] = true
|
||||
}
|
||||
|
||||
// IsEncrypted answers from the underlying store once the room's encryption state
|
||||
// has been resolved, fetching it from the server the first time.
|
||||
func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) {
|
||||
if !s.isEncryptionResolved(roomID) {
|
||||
unlock := s.lockRoom(roomID)
|
||||
defer unlock()
|
||||
|
||||
if !s.isEncryptionResolved(roomID) {
|
||||
var enc event.EncryptionEventContent
|
||||
err := s.client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc)
|
||||
switch {
|
||||
case err == nil:
|
||||
if enc.Algorithm != "" {
|
||||
if err := s.innerStateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil {
|
||||
return false, fmt.Errorf("cache encryption state for %s: %w", roomID, err)
|
||||
}
|
||||
}
|
||||
case errors.Is(err, mautrix.MNotFound):
|
||||
// No m.room.encryption at all: the room really is unencrypted.
|
||||
default:
|
||||
// Unresolved. Refuse rather than guess "unencrypted" and leak.
|
||||
return false, fmt.Errorf("resolve encryption state for %s: %w", roomID, err)
|
||||
}
|
||||
s.markEncryptionResolved(roomID)
|
||||
}
|
||||
}
|
||||
return s.innerStateStore.IsEncrypted(ctx, roomID)
|
||||
}
|
||||
|
||||
// GetEncryptionEvent is how the crypto machine reads a room's megolm settings
|
||||
// (session rotation period, etc). It needs the same resolution as IsEncrypted,
|
||||
// since an unresolved room would otherwise report no encryption event.
|
||||
func (s *lazyStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) {
|
||||
if _, err := s.IsEncrypted(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.innerStateStore.GetEncryptionEvent(ctx, roomID)
|
||||
}
|
||||
|
||||
// GetRoomJoinedOrInvitedMembers backs megolm session sharing. Without a sync
|
||||
// there is no member backfill, so an unfetched room would report zero members
|
||||
// and the bot would encrypt to an audience of nobody.
|
||||
func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) {
|
||||
fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
|
||||
}
|
||||
if !fetched {
|
||||
unlock := s.lockRoom(roomID)
|
||||
defer unlock()
|
||||
|
||||
if fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID); err != nil {
|
||||
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
|
||||
} else if !fetched {
|
||||
members, err := s.client.Members(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch members of %s: %w", roomID, err)
|
||||
}
|
||||
// No membership filter, so this also marks the room as fetched.
|
||||
if err := s.innerStateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil {
|
||||
return nil, fmt.Errorf("cache members of %s: %w", roomID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.innerStateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID)
|
||||
}
|
||||
195
internal/bot/statestore_test.go
Normal file
195
internal/bot/statestore_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// fakeHS serves the two endpoints the lazy store resolves against, counting hits
|
||||
// so we can assert it caches instead of re-asking on every send.
|
||||
type fakeHS struct {
|
||||
encryptionStatus int
|
||||
encryptionBody string
|
||||
membersBody string
|
||||
|
||||
encryptionHits atomic.Int32
|
||||
memberHits atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fakeHS) start(t *testing.T) *mautrix.Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/state/m.room.encryption"):
|
||||
f.encryptionHits.Add(1)
|
||||
w.WriteHeader(f.encryptionStatus)
|
||||
_, _ = w.Write([]byte(f.encryptionBody))
|
||||
case strings.HasSuffix(r.URL.Path, "/members"):
|
||||
f.memberHits.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(f.membersBody))
|
||||
default:
|
||||
t.Errorf("unexpected request to %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client, err := mautrix.NewClient(srv.URL, "@bot:example.org", "token")
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T, hs *fakeHS) *lazyStateStore {
|
||||
t.Helper()
|
||||
store := newLazyStateStore(mautrix.NewMemoryStateStore().(innerStateStore))
|
||||
store.client = hs.start(t)
|
||||
return store
|
||||
}
|
||||
|
||||
// The store is handed to the crypto helper, which type-asserts it and refuses to
|
||||
// start if it comes up short. That assertion is the whole bot's startup path, so
|
||||
// pin it here rather than discovering it as a crash loop in prod.
|
||||
func TestStoreSatisfiesCryptoHelper(t *testing.T) {
|
||||
store := newTestStore(t, &fakeHS{})
|
||||
|
||||
client := store.client
|
||||
client.StateStore = store
|
||||
|
||||
dir := t.TempDir()
|
||||
if _, err := cryptohelper.NewCryptoHelper(client, []byte("test"), filepath.Join(dir, "crypto.db")); err != nil {
|
||||
t.Fatalf("crypto helper rejected the state store, so the bot would not start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const roomID = id.RoomID("!room:example.org")
|
||||
|
||||
// The regression: a room configured as encrypted before the process started. No
|
||||
// transaction ever re-announces it, so the store must go ask.
|
||||
func TestIsEncryptedResolvesFromServer(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusOK,
|
||||
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsEncrypted: %v", err)
|
||||
}
|
||||
if !enc {
|
||||
t.Fatal("room is encrypted on the server but IsEncrypted said false — this is the plaintext leak")
|
||||
}
|
||||
}
|
||||
|
||||
// A room with no m.room.encryption really is plaintext, and must not re-fetch.
|
||||
func TestIsEncryptedCachesUnencrypted(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusNotFound,
|
||||
encryptionBody: `{"errcode":"M_NOT_FOUND","error":"Event not found."}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsEncrypted: %v", err)
|
||||
}
|
||||
if enc {
|
||||
t.Fatal("unencrypted room reported as encrypted")
|
||||
}
|
||||
}
|
||||
if got := hs.encryptionHits.Load(); got != 1 {
|
||||
t.Errorf("expected the M_NOT_FOUND to be cached, got %d fetches", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The important half: an unresolvable room must fail the send, not quietly
|
||||
// answer "not encrypted" and let the message out in the clear.
|
||||
func TestIsEncryptedFailsClosed(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusInternalServerError,
|
||||
encryptionBody: `{"errcode":"M_UNKNOWN","error":"oops"}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
if _, err := store.IsEncrypted(context.Background(), roomID); err == nil {
|
||||
t.Fatal("a failed lookup returned no error; the send would proceed in plaintext")
|
||||
}
|
||||
// The failure must not poison the cache: a later send has to retry.
|
||||
hs.encryptionStatus = http.StatusOK
|
||||
hs.encryptionBody = `{"algorithm":"m.megolm.v1.aes-sha2"}`
|
||||
enc, err := store.IsEncrypted(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsEncrypted after recovery: %v", err)
|
||||
}
|
||||
if !enc {
|
||||
t.Fatal("store did not retry after a transient failure")
|
||||
}
|
||||
}
|
||||
|
||||
// A burst of proactive sends into a cold room should resolve it once.
|
||||
func TestIsEncryptedResolvesOncePerRoom(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusOK,
|
||||
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := store.IsEncrypted(context.Background(), roomID); err != nil {
|
||||
t.Errorf("IsEncrypted: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := hs.encryptionHits.Load(); got != 1 {
|
||||
t.Errorf("expected 1 state fetch for 20 concurrent sends, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypting to an empty member list would share the group session with nobody,
|
||||
// so members must be resolved too.
|
||||
func TestMembersResolveFromServer(t *testing.T) {
|
||||
hs := &fakeHS{
|
||||
encryptionStatus: http.StatusOK,
|
||||
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
||||
membersBody: `{"chunk":[
|
||||
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@alice:example.org","sender":"@alice:example.org","content":{"membership":"join"}},
|
||||
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@bot:example.org","sender":"@bot:example.org","content":{"membership":"join"}}
|
||||
]}`,
|
||||
}
|
||||
store := newTestStore(t, hs)
|
||||
|
||||
members, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRoomJoinedOrInvitedMembers: %v", err)
|
||||
}
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("expected 2 members, got %d (%v) — the group session would be shared to nobody", len(members), members)
|
||||
}
|
||||
|
||||
if _, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID); err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if got := hs.memberHits.Load(); got != 1 {
|
||||
t.Errorf("expected members to be cached after one fetch, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,18 @@ func runMigrations(d *sql.DB) error {
|
||||
`ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE player_meta ADD COLUMN 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,
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
773
internal/plugin/adventure_mischief.go
Normal file
773
internal/plugin/adventure_mischief.go
Normal 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
|
||||
}
|
||||
445
internal/plugin/adventure_mischief_deliver.go
Normal file
445
internal/plugin/adventure_mischief_deliver.go
Normal 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)
|
||||
}
|
||||
564
internal/plugin/adventure_mischief_test.go
Normal file
564
internal/plugin/adventure_mischief_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
47
internal/plugin/bootstrap_boredom_clock.go
Normal file
47
internal/plugin/bootstrap_boredom_clock.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// bootstrapBoredomClock seeds player_meta.last_player_action_at for rows that
|
||||
// predate the column (gogobee_boredom_plan.md §1).
|
||||
//
|
||||
// Without this, the column is NULL for every existing player on the deploy that
|
||||
// adds it, and loadBoredomCandidates falls back to created_at — which is when
|
||||
// the character was *made*, not when it was last played. Every alive character
|
||||
// on the server is therefore months past the idle threshold on the first tick
|
||||
// after the deploy, and the whole player base gets marched into a dungeon at
|
||||
// once, coins debited, thirty minutes after restart. A player who typed a
|
||||
// command a minute before the deploy is no exception: the clock has never heard
|
||||
// of them.
|
||||
//
|
||||
// last_active_at is the best proxy we have for "the character did something
|
||||
// recently" and is exactly right at deploy time: recent for anyone still
|
||||
// playing, stale for anyone who isn't. It is unusable as the clock itself (the
|
||||
// autopilot bumps it through saveAdvCharacter, so a bored character would keep
|
||||
// refreshing its own idle clock — see markPlayerAction), which is why this is a
|
||||
// one-time seed and not a fallback in the query.
|
||||
//
|
||||
// Re-running on every boot is safe and intentionally a no-op:
|
||||
// - a row seeded here, or stamped by a real action, is no longer NULL;
|
||||
// - a character the boredom ticker has already sent out (last_boredom_at set)
|
||||
// is skipped, so a restart mid-boredom can't hand it a fresh idle clock off
|
||||
// the autopilot's own writes.
|
||||
func bootstrapBoredomClock() {
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE player_meta
|
||||
SET last_player_action_at = COALESCE(last_active_at, created_at)
|
||||
WHERE last_player_action_at IS NULL
|
||||
AND last_boredom_at IS NULL
|
||||
AND COALESCE(last_active_at, created_at) IS NOT NULL`)
|
||||
if err != nil {
|
||||
slog.Error("bootstrap: boredom clock seed failed", "err", err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
slog.Warn("bootstrap: seeded boredom idle clock from last_active_at", "rows", n)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
427
internal/plugin/exercise_boredom_prod_test.go
Normal file
427
internal/plugin/exercise_boredom_prod_test.go
Normal file
@@ -0,0 +1,427 @@
|
||||
//go:build prodexercise
|
||||
|
||||
package plugin
|
||||
|
||||
// Headless end-to-end exercise of the bored-adventurer path against a COPY of
|
||||
// the prod DB. This covers the one seam the unit tests can't reach: the
|
||||
// tryBoredomStart → beginExpedition → autopilot chain, which needs a live
|
||||
// EuroPlugin and a real character with real gear.
|
||||
//
|
||||
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||
// go test -tags prodexercise -run TestExerciseBoredomProd -v ./internal/plugin/
|
||||
//
|
||||
// As with TestExerciseNewFeaturesProd, the DB copy is re-copied into t.TempDir()
|
||||
// before opening, so even the copy is left pristine and nothing can reach prod.
|
||||
//
|
||||
// What it has to prove:
|
||||
// 1. The idle clock picks out exactly the idle players — a player who acted an
|
||||
// hour ago must NOT be swept up. (playerIsIdle fails open, so a regression
|
||||
// here marches the whole server into a dungeon at once.)
|
||||
// 2. A bored run actually starts against a real character: real zone, lean
|
||||
// supplies, coins debited.
|
||||
// 3. It buys and equips nothing. That's the whole mechanic.
|
||||
// 4. The expedition is flagged boredom=1, so the idle reaper won't let it
|
||||
// shield an absent player's streak.
|
||||
// 5. It re-entrantly refuses: a second tick inside the cooldown does nothing.
|
||||
// 6. The autopilot will actually walk it.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestExerciseBoredomProd(t *testing.T) {
|
||||
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||||
if srcDir == "" {
|
||||
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||||
t.Skipf("no gogobee.db in %s", srcDir)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||||
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||||
}
|
||||
db.Close()
|
||||
if err := db.Init(tmp); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
simOmenDisabled = true
|
||||
|
||||
euro := NewEuroPlugin(nil)
|
||||
xp := NewXPPlugin(nil)
|
||||
p := NewAdventurePlugin(nil, euro, xp)
|
||||
sink := &captureSink{}
|
||||
p.Sink = sink
|
||||
mark := 0
|
||||
drain := func() {
|
||||
for _, m := range sink.msgs[mark:] {
|
||||
who := string(m.ToUser)
|
||||
if who == "" {
|
||||
who = "room:" + string(m.ToRoom)
|
||||
}
|
||||
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||||
}
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
|
||||
chars := runnableChars(t)
|
||||
if len(chars) < 2 {
|
||||
t.Fatalf("need 2+ runnable characters in the DB copy, got %d", len(chars))
|
||||
}
|
||||
|
||||
// A pinned clock, 14:00 UTC today: outside the ambient quiet windows (06:00
|
||||
// briefing / 21:00 recap) that fireExpeditionBoredom deliberately refuses to
|
||||
// talk over, so the run is deterministic regardless of when we invoke it.
|
||||
now := time.Now().UTC().Truncate(24 * time.Hour).Add(14 * time.Hour)
|
||||
if inAmbientQuietWindow(now) {
|
||||
t.Fatal("pinned clock landed in the quiet window — pick another hour")
|
||||
}
|
||||
|
||||
// Clear the decks: nobody in the copy should be mid-expedition when we start,
|
||||
// or the structural guards will (correctly) refuse and prove nothing.
|
||||
for _, c := range chars {
|
||||
clearExpeditionState(t, c.uid)
|
||||
healToFull(t, c.uid)
|
||||
euro.Credit(c.uid, 2000, "boredom exercise bankroll")
|
||||
}
|
||||
|
||||
// The control arm. Without one, "everybody went" and "the clock works" look
|
||||
// identical — and the failure mode we're hunting (playerIsIdle failing open)
|
||||
// produces exactly the former.
|
||||
active := chars[0]
|
||||
idle := chars[1:]
|
||||
setLastAction(t, active.uid, now.Add(-1*time.Hour))
|
||||
for _, c := range idle {
|
||||
setLastAction(t, c.uid, now.Add(-72*time.Hour))
|
||||
}
|
||||
// Everyone else in the DB is left alone but must not be dragged along either;
|
||||
// snapshot who has an expedition now so we can diff at the end.
|
||||
before := activeExpeditionOwners(t)
|
||||
|
||||
t.Logf("──────── clock ────────")
|
||||
t.Logf(" control (acted 1h ago): %s L%d %s", active.uid, active.level, active.class)
|
||||
for _, c := range idle {
|
||||
t.Logf(" idle (72h silent): %s L%d %s", c.uid, c.level, c.class)
|
||||
}
|
||||
|
||||
// ── 1. The candidate query ──────────────────────────────────────────────
|
||||
cands, err := loadBoredomCandidates(now)
|
||||
if err != nil {
|
||||
t.Fatalf("loadBoredomCandidates: %v", err)
|
||||
}
|
||||
candSet := map[id.UserID]bool{}
|
||||
for _, u := range cands {
|
||||
candSet[u] = true
|
||||
}
|
||||
t.Logf(" loadBoredomCandidates → %d players", len(cands))
|
||||
if candSet[active.uid] {
|
||||
t.Errorf("control %s acted an hour ago but is a boredom candidate — the idle clock is not being read", active.uid)
|
||||
}
|
||||
for _, c := range idle {
|
||||
if !candSet[c.uid] {
|
||||
t.Errorf("idle %s has been silent 72h but is not a candidate", c.uid)
|
||||
}
|
||||
if playerIsIdle(active.uid, now) {
|
||||
t.Fatalf("playerIsIdle says the control player is idle — it is failing open, ABORT (this is the bug that marches the whole server into a dungeon)")
|
||||
}
|
||||
if !playerIsIdle(c.uid, now) {
|
||||
t.Errorf("playerIsIdle(%s) = false after 72h of silence", c.uid)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2/3/4. The run itself ───────────────────────────────────────────────
|
||||
type snap struct {
|
||||
balance float64
|
||||
gear string
|
||||
}
|
||||
pre := map[id.UserID]snap{}
|
||||
for _, c := range chars {
|
||||
pre[c.uid] = snap{balance: euro.GetBalance(c.uid), gear: gearFingerprint(t, c.uid)}
|
||||
}
|
||||
|
||||
t.Logf("──────── fireExpeditionBoredom ────────")
|
||||
p.fireExpeditionBoredom(now)
|
||||
drain()
|
||||
|
||||
for _, c := range idle {
|
||||
exp, err := getActiveExpedition(c.uid)
|
||||
if err != nil || exp == nil {
|
||||
t.Errorf("%s (L%d): no expedition started (err=%v) — the boredom start path is broken", c.uid, c.level, err)
|
||||
continue
|
||||
}
|
||||
zone, ok := getZone(exp.ZoneID)
|
||||
if !ok {
|
||||
t.Errorf("%s: started an unknown zone %q", c.uid, exp.ZoneID)
|
||||
continue
|
||||
}
|
||||
spent := pre[c.uid].balance - euro.GetBalance(c.uid)
|
||||
lean := loadoutPurchase(zone.Tier, LoadoutLean)
|
||||
t.Logf(" %s L%d → %s (T%d) — spent %.0f coins (lean = %d)",
|
||||
c.uid, c.level, zone.Display, int(zone.Tier), spent, lean.Cost())
|
||||
|
||||
// In band.
|
||||
if c.level < zone.LevelMin || c.level > zone.LevelMax {
|
||||
t.Errorf("%s L%d sent to %s, band %d–%d — out of band",
|
||||
c.uid, c.level, zone.ID, zone.LevelMin, zone.LevelMax)
|
||||
}
|
||||
// Lean supplies, exactly. Anything else means it went shopping.
|
||||
if int(spent) != lean.Cost() {
|
||||
t.Errorf("%s spent %.0f coins, lean pack for T%d is %d — it bought something it shouldn't have",
|
||||
c.uid, spent, int(zone.Tier), lean.Cost())
|
||||
}
|
||||
// Gear untouched. This is the mechanic, not a nicety.
|
||||
if got := gearFingerprint(t, c.uid); got != pre[c.uid].gear {
|
||||
t.Errorf("%s: equipment changed across a bored run.\n was: %s\n now: %s", c.uid, pre[c.uid].gear, got)
|
||||
}
|
||||
// Flagged, so the idle reaper won't let it hold their streak.
|
||||
if !expeditionIsBoredom(t, exp.ID) {
|
||||
t.Errorf("%s: expedition %s not flagged boredom=1 — it will shield an absent player's streak", c.uid, exp.ID)
|
||||
}
|
||||
if !isBoredomDriven(c.uid, now) {
|
||||
t.Errorf("isBoredomDriven(%s) = false right after a bored start", c.uid)
|
||||
}
|
||||
// Raid only where there was no alternative.
|
||||
if w := raidContentWarning(zone.ID); w != "" && c.level < 13 {
|
||||
t.Errorf("%s L%d was sent into raid zone %s while non-raid zones were in band", c.uid, c.level, zone.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// The control player must be exactly where we left them.
|
||||
if exp, _ := getActiveExpedition(active.uid); exp != nil {
|
||||
t.Errorf("control %s acted an hour ago and was still sent on an expedition (%s)", active.uid, exp.ZoneID)
|
||||
}
|
||||
if b := euro.GetBalance(active.uid); b != pre[active.uid].balance {
|
||||
t.Errorf("control %s was charged %.0f coins for an expedition they didn't take",
|
||||
active.uid, pre[active.uid].balance-b)
|
||||
}
|
||||
// The sweep reaches every idle player in the DB, not just the two we staged —
|
||||
// that's the feature working, not a leak. The invariant that actually matters
|
||||
// is that *everyone who left was idle*. (runnableChars only lists characters
|
||||
// with the legacy adventure_characters row, so the DB holds idle players it
|
||||
// doesn't return; they're still real characters and they're right to go.)
|
||||
departed := diffOwners(activeExpeditionOwners(t), before)
|
||||
for _, uid := range departed {
|
||||
if !playerIsIdle(uid, now) {
|
||||
t.Errorf("%s was sent on an expedition but is NOT idle — the clock is not gating the sweep", uid)
|
||||
}
|
||||
}
|
||||
t.Logf(" departed: %d of %d candidates", len(departed), len(cands))
|
||||
// The gap between candidates and departures is the structural guards doing
|
||||
// their job (mid-run, resting, unfinished setup, broke). Name them, so a
|
||||
// silent refusal can't hide here.
|
||||
left := map[id.UserID]bool{}
|
||||
for _, uid := range departed {
|
||||
left[uid] = true
|
||||
}
|
||||
for _, uid := range cands {
|
||||
if !left[uid] {
|
||||
t.Logf(" stayed home: %-28s %s", uid, whyNoBoredomRun(uid))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. The cooldown ─────────────────────────────────────────────────────
|
||||
t.Logf("──────── second tick (must be a no-op) ────────")
|
||||
balBefore := map[id.UserID]float64{}
|
||||
for _, c := range idle {
|
||||
balBefore[c.uid] = euro.GetBalance(c.uid)
|
||||
}
|
||||
p.fireExpeditionBoredom(now.Add(30 * time.Minute))
|
||||
drain()
|
||||
for _, c := range idle {
|
||||
if b := euro.GetBalance(c.uid); b != balBefore[c.uid] {
|
||||
t.Errorf("%s was charged %.0f more coins on a second tick inside the cooldown — the CAS claim isn't holding",
|
||||
c.uid, balBefore[c.uid]-b)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Does it actually walk? ───────────────────────────────────────────
|
||||
t.Logf("──────── autopilot walks the bored run ────────")
|
||||
for _, c := range idle {
|
||||
exp, _ := getActiveExpedition(c.uid)
|
||||
if exp == nil {
|
||||
continue
|
||||
}
|
||||
ctx := MessageContext{Sender: c.uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: ""}
|
||||
// Several segments, as the ambient ticker would over a day. One call can
|
||||
// legitimately stop early (stopHarvestCombat, a fork, a boss doorway); the
|
||||
// question is whether the run keeps making progress when it's called again.
|
||||
total := 0
|
||||
for seg := 0; seg < 4; seg++ {
|
||||
r := p.runAutopilotWalkDriven(ctx, 6)
|
||||
if r.initErr != "" {
|
||||
t.Errorf("%s: autopilot refused to walk the bored expedition: %s", c.uid, r.initErr)
|
||||
break
|
||||
}
|
||||
total += r.rooms
|
||||
t.Logf(" %s seg%d: +%d rooms (total %d), stopped: %s", c.uid, seg, r.rooms, total, stopName(r.reason))
|
||||
if r.reason == stopComplete || r.reason == stopEnded {
|
||||
break
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
t.Errorf("%s: the bored expedition never took a single step", c.uid)
|
||||
}
|
||||
}
|
||||
drain()
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// clearExpeditionState puts the character back on the doorstep: no expedition,
|
||||
// no zone run, no combat session. The prod copy is a live snapshot, so some
|
||||
// characters are mid-run — and the structural guards would (correctly) refuse
|
||||
// to start a bored run on top of that, which would test nothing.
|
||||
func clearExpeditionState(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
for _, q := range []string{
|
||||
`UPDATE dnd_expedition SET status = 'complete' WHERE user_id = ? AND status IN ('active','extracted')`,
|
||||
`DELETE FROM dnd_zone_run WHERE user_id = ?`,
|
||||
`DELETE FROM combat_session WHERE user_id = ?`,
|
||||
`UPDATE player_meta SET last_boredom_at = NULL WHERE user_id = ?`,
|
||||
} {
|
||||
if _, err := db.Get().Exec(q, string(uid)); err != nil {
|
||||
t.Logf(" clearExpeditionState(%s): %v (continuing)", uid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setLastAction(t *testing.T, uid id.UserID, at time.Time) {
|
||||
t.Helper()
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
|
||||
at, string(uid)); err != nil {
|
||||
t.Fatalf("setLastAction(%s): %v", uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// gearFingerprint is what the bored run must not change.
|
||||
func gearFingerprint(t *testing.T, uid id.UserID) string {
|
||||
t.Helper()
|
||||
eq, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return "ERR:" + err.Error()
|
||||
}
|
||||
var parts []string
|
||||
for slot, e := range eq {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s=%s/T%d", slot, e.Name, e.Tier))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return fmt.Sprint(parts)
|
||||
}
|
||||
|
||||
func expeditionIsBoredom(t *testing.T, expID string) bool {
|
||||
t.Helper()
|
||||
var b bool
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT boredom FROM dnd_expedition WHERE expedition_id = ?`, expID).Scan(&b); err != nil {
|
||||
t.Logf(" expeditionIsBoredom(%s): %v", expID, err)
|
||||
return false
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func activeExpeditionOwners(t *testing.T) map[id.UserID]bool {
|
||||
t.Helper()
|
||||
out := map[id.UserID]bool{}
|
||||
rows, err := db.Get().Query(`SELECT user_id FROM dnd_expedition WHERE status = 'active'`)
|
||||
if err != nil {
|
||||
t.Fatalf("activeExpeditionOwners: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[id.UserID(s)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// whyNoBoredomRun re-runs tryBoredomStart's guards for a candidate who didn't
|
||||
// leave, so the candidates-vs-departures gap is explained rather than assumed.
|
||||
func whyNoBoredomRun(uid id.UserID) string {
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
switch {
|
||||
case err != nil:
|
||||
return "character won't load: " + err.Error()
|
||||
case c == nil:
|
||||
return "no character"
|
||||
case c.PendingSetup:
|
||||
return "character creation never finished"
|
||||
}
|
||||
if restingLockoutRemaining(c) > 0 {
|
||||
return "resting lockout"
|
||||
}
|
||||
if hasActiveCombatSession(uid) {
|
||||
return "in combat"
|
||||
}
|
||||
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||||
return "seated on someone else's party"
|
||||
}
|
||||
if active, _ := getActiveExpedition(uid); active != nil {
|
||||
return "already on an expedition"
|
||||
}
|
||||
if pending, _ := getResumableExpedition(uid); pending != nil {
|
||||
return "extracted, waiting on !resume"
|
||||
}
|
||||
if run, _ := getActiveZoneRun(uid); run != nil {
|
||||
return "mid zone run"
|
||||
}
|
||||
zone, ok := pickBoredomZone(uid, c.Level)
|
||||
if !ok {
|
||||
return fmt.Sprintf("no zone in band for L%d", c.Level)
|
||||
}
|
||||
return fmt.Sprintf("(no guard tripped — would have gone to %s; check cooldown/coins)", zone.ID)
|
||||
}
|
||||
|
||||
func stopName(r stopReason) string {
|
||||
switch r {
|
||||
case stopOK:
|
||||
return "ok"
|
||||
case stopFork:
|
||||
return "fork"
|
||||
case stopElite:
|
||||
return "elite doorway"
|
||||
case stopBoss:
|
||||
return "boss doorway"
|
||||
case stopEnded:
|
||||
return "died"
|
||||
case stopComplete:
|
||||
return "zone cleared"
|
||||
case stopBlocked:
|
||||
return "blocked by combat session"
|
||||
case stopHarvestCombat:
|
||||
return "harvest pulled a fight"
|
||||
case stopPreflight:
|
||||
return "preflight (low HP/SU)"
|
||||
case stopBossSafety:
|
||||
return "boss-safety gate"
|
||||
}
|
||||
return fmt.Sprintf("stopReason(%d)", int(r))
|
||||
}
|
||||
|
||||
func diffOwners(after, before map[id.UserID]bool) []id.UserID {
|
||||
var out []id.UserID
|
||||
for uid := range after {
|
||||
if !before[uid] {
|
||||
out = append(out, uid)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
459
internal/plugin/expedition_boredom.go
Normal file
459
internal/plugin/expedition_boredom.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/flavor"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Bored adventurers (gogobee_boredom_plan.md).
|
||||
//
|
||||
// A player who stops tending their adventurer doesn't stop having one. After
|
||||
// boredomIdleThreshold with no action against Adventure, the character gets
|
||||
// restless and leaves on an expedition by itself: the easiest zone its level
|
||||
// band allows, the cheapest supplies it can afford, and whatever gear was
|
||||
// already on the rack.
|
||||
//
|
||||
// Everything downstream of the start is already autonomous — the autopilot
|
||||
// (expedition_autorun.go) walks rooms, drives elite and boss fights on the real
|
||||
// turn engine, camps, harvests, rolls days over, and auto-picks forks after 8h.
|
||||
// The only thing that ever needed a human was `!expedition start`, so that is
|
||||
// the only thing this file does.
|
||||
//
|
||||
// It never buys or equips gear, and that is the entire mechanic: a neglected
|
||||
// adventurer grinds half-starved runs on rusting equipment and comes home taxed.
|
||||
|
||||
const (
|
||||
// boredomIdleThreshold — silence, measured against the player's last
|
||||
// action *against Adventure*, before the adventurer leaves on its own.
|
||||
boredomIdleThreshold = 24 * time.Hour
|
||||
|
||||
// boredomCooldown — minimum gap between boredom starts for one player.
|
||||
// Also the re-trigger cadence: when a bored expedition ends, another
|
||||
// boredomCooldown of silence starts the next one. An abandoned character
|
||||
// keeps going until it runs out of coins for supplies.
|
||||
boredomCooldown = 24 * time.Hour
|
||||
|
||||
// boredomTickInterval — how often the ticker wakes. The cooldown gate is
|
||||
// what actually paces starts; the tick only has to be frequent enough to
|
||||
// enforce it cleanly.
|
||||
boredomTickInterval = 30 * time.Minute
|
||||
)
|
||||
|
||||
// ── The idle clock ───────────────────────────────────────────────────────────
|
||||
|
||||
// markPlayerAction stamps player_meta.last_player_action_at — the clock the
|
||||
// boredom ticker reads.
|
||||
//
|
||||
// It is a direct UPDATE and deliberately does NOT route through
|
||||
// saveAdvCharacter. Every other candidate timestamp in the schema is unusable
|
||||
// here for the same reason (gogobee_boredom_plan.md §1):
|
||||
//
|
||||
// - last_active_at is auto-bumped inside saveAdvCharacter, so the autopilot's
|
||||
// own combat writes would refresh a bored character's idle clock and it
|
||||
// would never qualify again.
|
||||
// - user_stats.updated_at is bumped by any Matrix message in any room. That's
|
||||
// chat presence, not a game action — a player can talk all day without
|
||||
// touching their adventurer, and the adventurer should still get bored.
|
||||
// - loadAdvDailyActivity is unified across dnd_expedition_log, so the
|
||||
// autopilot's own walk entries read back as player activity.
|
||||
//
|
||||
// Any future non-Matrix entry point (web, API, Pete) should call this too — the
|
||||
// clock tracks actions against Adventure, not Matrix traffic.
|
||||
func markPlayerAction(userID id.UserID) {
|
||||
db.Exec("boredom: mark player action",
|
||||
`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
|
||||
time.Now().UTC(), string(userID))
|
||||
}
|
||||
|
||||
// advActionCommands — every `!command` routed by AdventurePlugin.OnMessage.
|
||||
//
|
||||
// This is NOT Commands() (adventure.go): that registers 28 names for the help
|
||||
// surface and is missing expedition, zone, fight, camp, extract, resume and
|
||||
// more. Routing is a 47-branch if-chain, and the boredom clock has to see all
|
||||
// of it, so the set is spelled out here. TestAdvActionCommandsCoverDispatch
|
||||
// greps the IsCommand literals out of adventure.go and fails if one is missing,
|
||||
// so a new command can't silently fall out of the clock.
|
||||
var advActionCommands = map[string]bool{
|
||||
"abilities": true, "adv": true, "adventure": true, "arena": true,
|
||||
"arm": true, "attack": true, "bail": true, "camp": true,
|
||||
"cast": true, "check": true, "commune": true, "consume": true,
|
||||
"craft": true, "duel": true, "essence": true, "expedition": true,
|
||||
"explore": true, "extract": true, "fight": true, "fish": true,
|
||||
"flee": true, "forage": true, "give": true, "graveyard": true,
|
||||
"hospital": true, "level": true, "lore": true, "map": true,
|
||||
"mine": true, "mischief": true, "news": true, "prepare": true, "region": true,
|
||||
"resources": true, "respec": true, "rest": true, "resume": true,
|
||||
"revisit": true, "rivals": true, "roll": true, "scavenge": true,
|
||||
"sell": true, "setup": true, "sheet": true, "spells": true,
|
||||
"stats": true, "subclass": true, "thom": true, "threat": true,
|
||||
"town": true, "zone": true,
|
||||
}
|
||||
|
||||
// isPlayerAdventureAction reports whether this message is the player doing
|
||||
// something to their adventurer — an Adventure command, or a reply to a prompt
|
||||
// we're holding open for them (shop, blacksmith, pet, treasure). Both count:
|
||||
// answering "2" to a shop menu is as much an action as typing `!shop`.
|
||||
//
|
||||
// This runs on every message the bot sees, in every room, so the command test
|
||||
// is a single tokenise-and-look-up rather than 50 passes of IsCommand over the
|
||||
// body. It matches util.IsCommand's rule exactly: trimmed, lowercased, the
|
||||
// command is the whole body or is followed by a space.
|
||||
//
|
||||
// The pending-prompt check honours ExpiresAt. Nothing sweeps p.pending, so an
|
||||
// abandoned prompt (offered, never answered) sits there forever — without the
|
||||
// expiry test, every idle remark that player ever makes in any room would read
|
||||
// as tending their adventurer, and the clock would never run down.
|
||||
func (p *AdventurePlugin) isPlayerAdventureAction(ctx MessageContext) bool {
|
||||
body := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
if strings.HasPrefix(body, p.Prefix) {
|
||||
word := strings.TrimPrefix(body, p.Prefix)
|
||||
if i := strings.IndexByte(word, ' '); i >= 0 {
|
||||
word = word[:i]
|
||||
}
|
||||
if advActionCommands[word] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if v, ok := p.pending.Load(string(ctx.Sender)); ok {
|
||||
pi, isPrompt := v.(*advPendingInteraction)
|
||||
// A zero ExpiresAt is a prompt with no deadline, not an expired one.
|
||||
if isPrompt && (pi.ExpiresAt.IsZero() || time.Now().Before(pi.ExpiresAt)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── The ticker ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) expeditionBoredomTicker() {
|
||||
ticker := time.NewTicker(boredomTickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.fireExpeditionBoredom(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireExpeditionBoredom sends every eligible idle adventurer into a dungeon.
|
||||
func (p *AdventurePlugin) fireExpeditionBoredom(now time.Time) {
|
||||
// Same politeness gate as the ambient ticker: don't drop a departure DM on
|
||||
// top of the 06:00 briefing or 21:00 recap.
|
||||
if inAmbientQuietWindow(now) {
|
||||
return
|
||||
}
|
||||
uids, err := loadBoredomCandidates(now)
|
||||
if err != nil {
|
||||
slog.Error("boredom: load candidates", "err", err)
|
||||
return
|
||||
}
|
||||
for _, uid := range uids {
|
||||
if err := p.tryBoredomStart(uid, now); err != nil {
|
||||
slog.Warn("boredom: start failed", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadBoredomCandidates returns players whose last action against Adventure is
|
||||
// older than boredomIdleThreshold and who haven't been sent out inside the
|
||||
// cooldown.
|
||||
//
|
||||
// COALESCE onto created_at so a character that was made and then never played
|
||||
// still qualifies — but not on its very first tick, since created_at is the
|
||||
// clock until they touch anything.
|
||||
func loadBoredomCandidates(now time.Time) ([]id.UserID, error) {
|
||||
idleCutoff := now.Add(-boredomIdleThreshold)
|
||||
coolCutoff := now.Add(-boredomCooldown)
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT user_id
|
||||
FROM player_meta
|
||||
WHERE alive = 1
|
||||
AND COALESCE(last_player_action_at, created_at) IS NOT NULL
|
||||
AND COALESCE(last_player_action_at, created_at) < ?
|
||||
AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
|
||||
idleCutoff, coolCutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []id.UserID
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id.UserID(s))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// tryBoredomStart runs the eligibility guards for one player and, if they pass,
|
||||
// outfits and launches the expedition.
|
||||
func (p *AdventurePlugin) tryBoredomStart(uid id.UserID, now time.Time) error {
|
||||
// The foreground commands mutate the same rows under this lock; a boredom
|
||||
// start racing `!expedition start` would double-book the character.
|
||||
userMu := p.advUserLock(uid)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == nil || c.PendingSetup {
|
||||
return nil // never finished character creation — nothing to get bored
|
||||
}
|
||||
|
||||
// Structural blockers. Every one of these is a state the player has to
|
||||
// resolve themselves; the adventurer can't just walk out of it. Mirrors the
|
||||
// guard set in expeditionCmdStart.
|
||||
if restingLockoutRemaining(c) > 0 {
|
||||
return nil
|
||||
}
|
||||
if hasActiveCombatSession(uid) {
|
||||
return nil
|
||||
}
|
||||
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||||
return nil // riding someone else's party
|
||||
}
|
||||
if active, _ := getActiveExpedition(uid); active != nil {
|
||||
return nil
|
||||
}
|
||||
if pending, _ := getResumableExpedition(uid); pending != nil {
|
||||
return nil // extracted, holding a roster for `!resume` — theirs to settle
|
||||
}
|
||||
if run, _ := getActiveZoneRun(uid); run != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
zone, ok := pickBoredomZone(uid, c.Level)
|
||||
if !ok {
|
||||
return nil // no zone this character's level band can be sent to
|
||||
}
|
||||
|
||||
// Claim the cooldown BEFORE spending anything. If the start then fails, or
|
||||
// the process dies mid-flight, we wait out the cooldown instead of looping
|
||||
// on a broken character every 30 minutes.
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE player_meta
|
||||
SET last_boredom_at = ?
|
||||
WHERE user_id = ?
|
||||
AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
|
||||
now, string(uid), now.Add(-boredomCooldown))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil // another tick claimed it
|
||||
}
|
||||
|
||||
// Cheapest pack that exists. A zero-supply start is impossible —
|
||||
// SupplyPurchase.Validate rejects it — so a bored run always costs coins,
|
||||
// and a broke adventurer simply doesn't go. That's the floor that stops an
|
||||
// abandoned character looping forever.
|
||||
purchase := loadoutPurchase(zone.Tier, LoadoutLean)
|
||||
cost := float64(purchase.Cost())
|
||||
if p.euro == nil {
|
||||
return nil
|
||||
}
|
||||
if balance := p.euro.GetBalance(uid); balance < cost {
|
||||
return p.SendDM(uid, fmt.Sprintf(
|
||||
"🪙 I got as far as the supply counter. Outfitting for **%s** runs **%d** coins and we have **%.0f**. So I've put the pack back and come home. Nothing to be done about it from where I'm standing.",
|
||||
zone.Display, int(cost), balance))
|
||||
}
|
||||
|
||||
exp, supplies, _, err := p.beginExpedition(uid, c.Level, zone, purchase, "expedition outfitting (restless)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark it as self-started. The idle reaper reads this so an expedition the
|
||||
// player never asked for can't hold their streak or spare them the shame DM
|
||||
// (gogobee_boredom_plan.md §6).
|
||||
db.Exec("boredom: flag expedition",
|
||||
`UPDATE dnd_expedition SET boredom = 1 WHERE expedition_id = ?`, exp.ID)
|
||||
|
||||
slog.Info("boredom: adventurer left on its own",
|
||||
"user", uid, "zone", zone.ID, "level", c.Level, "cost", int(cost))
|
||||
emitBoredomDeparture(uid, zone, c.Level)
|
||||
return p.SendDM(uid, renderBoredomDepartureDM(zone, supplies, purchase))
|
||||
}
|
||||
|
||||
// ── Reading the idle clock ───────────────────────────────────────────────────
|
||||
|
||||
// playerIsIdle reports whether the player has gone boredomIdleThreshold without
|
||||
// an action against Adventure. A player with no recorded action at all (never
|
||||
// played, or predates the column) counts as idle.
|
||||
// The COALESCE is done in Go, not SQL, on purpose. modernc.org/sqlite rebuilds
|
||||
// a time.Time from the column's declared type, and COALESCE() erases it — the
|
||||
// value comes back a string and the Scan fails. Selecting the two declared
|
||||
// DATETIME columns and folding them here keeps the affinity. This function
|
||||
// fails open (an unreadable row reads as idle), so a broken scan here would
|
||||
// declare every player idle and march the whole server into a dungeon at once.
|
||||
func playerIsIdle(userID id.UserID, now time.Time) bool {
|
||||
var lastAction, created *time.Time
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT last_player_action_at, created_at FROM player_meta WHERE user_id = ?`,
|
||||
string(userID)).Scan(&lastAction, &created)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
last := lastAction
|
||||
if last == nil {
|
||||
last = created // never acted — the clock runs from when they were made
|
||||
}
|
||||
if last == nil {
|
||||
return true
|
||||
}
|
||||
return last.Before(now.Add(-boredomIdleThreshold))
|
||||
}
|
||||
|
||||
// isBoredomDriven reports that this character is running on its own and its
|
||||
// player has not come back: the last expedition they set out on was one the
|
||||
// adventurer started for itself, and they still haven't touched Adventure.
|
||||
//
|
||||
// This is the gate the idle reaper uses. It's deliberately narrower than "is
|
||||
// the player idle": an expedition the player *did* start still holds their
|
||||
// streak while the autopilot walks it, exactly as it always has. Only an
|
||||
// expedition nobody asked for loses that protection.
|
||||
//
|
||||
// Reading the most recent expedition rather than the active one keeps it honest
|
||||
// for the night after a bored run ends — the logs it left behind would
|
||||
// otherwise read as player activity for another day.
|
||||
func isBoredomDriven(userID id.UserID, now time.Time) bool {
|
||||
if !playerIsIdle(userID, now) {
|
||||
return false // they came back; nothing to reap
|
||||
}
|
||||
var boredom bool
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT boredom
|
||||
FROM dnd_expedition
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date DESC
|
||||
LIMIT 1`, string(userID)).Scan(&boredom)
|
||||
if err != nil {
|
||||
return false // no expedition history, or unreadable — don't reap on a guess
|
||||
}
|
||||
return boredom
|
||||
}
|
||||
|
||||
// ── Zone selection ───────────────────────────────────────────────────────────
|
||||
|
||||
// pickBoredomZone chooses where a restless adventurer goes.
|
||||
//
|
||||
// It uses the LevelMin/LevelMax bands on ZoneDefinition rather than
|
||||
// zonesForLevel, which filters on tier alone and ignores those fields — which
|
||||
// is why a level-1 player can currently walk into a T3 zone. A bored adventurer
|
||||
// should neither farm trivial content nor wander somewhere absurd, so the band
|
||||
// is the right gate.
|
||||
//
|
||||
// Order of preference:
|
||||
// 1. Zones whose level band contains the character.
|
||||
// 2. Among those, the non-raid ones (raidContentWarning) — the party-tuned T5
|
||||
// bosses have a 0% solo clear rate, so they're avoided while an alternative
|
||||
// exists.
|
||||
// 3. Among those, the lowest tier: the "easiest at-level" rule.
|
||||
// 4. Ties break on least-recently-visited, so a bored adventurer rotates zones
|
||||
// instead of grinding one forever.
|
||||
//
|
||||
// If step 2 leaves nothing, raid zones are allowed back in. That only bites at
|
||||
// L13+, where dragons_lair and abyss_portal are the only zones in band and both
|
||||
// are raids. Those runs cannot be won solo — they wall at the boss-safety gate,
|
||||
// starve, and force-extract with the haul tax. That is the intended fate of an
|
||||
// abandoned endgame character, and it drains the coins that fund the next one.
|
||||
func pickBoredomZone(uid id.UserID, level int) (ZoneDefinition, bool) {
|
||||
var inBand, nonRaid []ZoneDefinition
|
||||
for _, z := range allZones() {
|
||||
if level < z.LevelMin || level > z.LevelMax {
|
||||
continue
|
||||
}
|
||||
inBand = append(inBand, z)
|
||||
if raidContentWarning(z.ID) == "" {
|
||||
nonRaid = append(nonRaid, z)
|
||||
}
|
||||
}
|
||||
pool := nonRaid
|
||||
if len(pool) == 0 {
|
||||
pool = inBand
|
||||
}
|
||||
if len(pool) == 0 {
|
||||
return ZoneDefinition{}, false
|
||||
}
|
||||
|
||||
lastVisit, err := lastExpeditionByZone(uid)
|
||||
if err != nil {
|
||||
slog.Warn("boredom: zone history unavailable, falling back to tier order", "user", uid, "err", err)
|
||||
lastVisit = map[ZoneID]time.Time{}
|
||||
}
|
||||
sort.SliceStable(pool, func(i, j int) bool {
|
||||
if pool[i].Tier != pool[j].Tier {
|
||||
return pool[i].Tier < pool[j].Tier
|
||||
}
|
||||
return lastVisit[pool[i].ID].Before(lastVisit[pool[j].ID])
|
||||
})
|
||||
return pool[0], true
|
||||
}
|
||||
|
||||
// lastExpeditionByZone returns when this player last set out for each zone.
|
||||
// Zones they've never visited are absent, so they sort first (zero time).
|
||||
//
|
||||
// No MAX(start_date)/GROUP BY: the aggregate erases the column's declared type
|
||||
// and modernc.org/sqlite hands back a string the Scan can't take. Walking the
|
||||
// rows in ascending order and letting each overwrite the last gives the same
|
||||
// answer with the affinity intact.
|
||||
func lastExpeditionByZone(uid id.UserID) (map[ZoneID]time.Time, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT zone_id, start_date
|
||||
FROM dnd_expedition
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date ASC`, string(uid))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[ZoneID]time.Time{}
|
||||
for rows.Next() {
|
||||
var zone string
|
||||
var last time.Time
|
||||
if err := rows.Scan(&zone, &last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[ZoneID(zone)] = last // ascending, so the last write is the most recent
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
// renderBoredomDepartureDM is the note left on the table. It states plainly
|
||||
// that nothing was bought and nothing was upgraded — the player should be able
|
||||
// to read this and know exactly why the haul is going to be bad.
|
||||
func renderBoredomDepartureDM(zone ZoneDefinition, supplies ExpeditionSupplies, purchase SupplyPurchase) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🎒 **Gone on ahead — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||||
b.WriteString("_" + zone.Hook + "_\n\n")
|
||||
b.WriteString(flavor.Pick(flavor.ExpeditionBoredomStart))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(fmt.Sprintf("**Packed:** %.0f SU (%d standard) — %d coins, the cheapest that would get us through the gate\n",
|
||||
supplies.Max, purchase.StandardPacks, purchase.Cost()))
|
||||
b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — roughly **%d days** before we're rationing\n",
|
||||
supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn)))
|
||||
b.WriteString("**Equipment:** unchanged. I don't do the shopping.\n\n")
|
||||
if w := raidContentWarning(zone.ID); w != "" {
|
||||
b.WriteString(w)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("`!expedition status` to see where we've got to. Come and find us.")
|
||||
return b.String()
|
||||
}
|
||||
261
internal/plugin/expedition_boredom_clock_test.go
Normal file
261
internal/plugin/expedition_boredom_clock_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// These exercise the idle clock against real rows.
|
||||
//
|
||||
// The hazard they exist for: modernc.org/sqlite rebuilds a time.Time from the
|
||||
// column's *declared* type, and both COALESCE() and MAX() erase it. A Scan that
|
||||
// silently fails here is not a small bug — playerIsIdle fails open (a player it
|
||||
// can't read counts as idle), so a broken scan would declare the entire server
|
||||
// idle and march everyone into a dungeon at once.
|
||||
|
||||
func seedBoredomPlayer(t *testing.T, uid id.UserID, created, lastAction, lastBoredom *time.Time) {
|
||||
t.Helper()
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at, last_boredom_at)
|
||||
VALUES (?, ?, 1, ?, ?, ?)`,
|
||||
string(uid), "Test", created, lastAction, lastBoredom)
|
||||
if err != nil {
|
||||
t.Fatalf("seed player_meta: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerIsIdleReadsTheClock(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-72 * time.Hour)
|
||||
recent := now.Add(-1 * time.Hour)
|
||||
|
||||
seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
|
||||
seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
|
||||
// Never acted: the COALESCE falls through to created_at.
|
||||
seedBoredomPlayer(t, "@never:test", &old, nil, nil)
|
||||
seedBoredomPlayer(t, "@fresh:test", &recent, nil, nil)
|
||||
|
||||
cases := []struct {
|
||||
uid id.UserID
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{"@idle:test", true, "last acted 72h ago"},
|
||||
{"@active:test", false, "acted an hour ago"},
|
||||
{"@never:test", true, "never acted, created 72h ago — COALESCE to created_at"},
|
||||
{"@fresh:test", false, "never acted, but only just created — must not be yanked out on day one"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := playerIsIdle(tc.uid, now); got != tc.want {
|
||||
t.Errorf("playerIsIdle(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBoredomCandidatesRespectsClockAndCooldown(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-72 * time.Hour)
|
||||
recent := now.Add(-1 * time.Hour)
|
||||
|
||||
seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
|
||||
seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
|
||||
// Idle, but already sent out an hour ago — the cooldown holds them back.
|
||||
seedBoredomPlayer(t, "@cooling:test", &old, &old, &recent)
|
||||
// Idle, and their last outing was days ago — eligible again.
|
||||
seedBoredomPlayer(t, "@ready:test", &old, &old, &old)
|
||||
|
||||
got, err := loadBoredomCandidates(now)
|
||||
if err != nil {
|
||||
t.Fatalf("loadBoredomCandidates: %v", err)
|
||||
}
|
||||
set := map[id.UserID]bool{}
|
||||
for _, u := range got {
|
||||
set[u] = true
|
||||
}
|
||||
for uid, want := range map[id.UserID]bool{
|
||||
"@idle:test": true,
|
||||
"@ready:test": true,
|
||||
"@active:test": false,
|
||||
"@cooling:test": false,
|
||||
} {
|
||||
if set[uid] != want {
|
||||
t.Errorf("candidate %s: got %v, want %v", uid, set[uid], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkPlayerActionResetsTheClock(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-72 * time.Hour)
|
||||
seedBoredomPlayer(t, "@back:test", &old, &old, nil)
|
||||
|
||||
if !playerIsIdle("@back:test", now) {
|
||||
t.Fatal("precondition: player should start idle")
|
||||
}
|
||||
markPlayerAction("@back:test")
|
||||
if playerIsIdle("@back:test", now) {
|
||||
t.Error("player acted against Adventure but is still counted idle — the clock isn't being stamped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsBoredomDrivenNeedsBothHalves pins the rule the idle reaper depends on:
|
||||
// only an expedition the player never asked for, from a player who still hasn't
|
||||
// come back, loses its streak protection. A self-started expedition holds it.
|
||||
func TestIsBoredomDrivenNeedsBothHalves(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-72 * time.Hour)
|
||||
recent := now.Add(-1 * time.Hour)
|
||||
|
||||
seedExp := func(t *testing.T, uid id.UserID, boredom bool) {
|
||||
t.Helper()
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date, boredom)
|
||||
VALUES (?, ?, 'goblin_warrens', 'active', ?, ?)`,
|
||||
string(uid)+"-exp", string(uid), old, boredom)
|
||||
if err != nil {
|
||||
t.Fatalf("seed expedition: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
seedBoredomPlayer(t, "@bored:test", &old, &old, nil)
|
||||
seedExp(t, "@bored:test", true)
|
||||
|
||||
seedBoredomPlayer(t, "@selfstart:test", &old, &old, nil)
|
||||
seedExp(t, "@selfstart:test", false)
|
||||
|
||||
seedBoredomPlayer(t, "@returned:test", &old, &recent, nil)
|
||||
seedExp(t, "@returned:test", true)
|
||||
|
||||
seedBoredomPlayer(t, "@noexp:test", &old, &old, nil)
|
||||
|
||||
cases := []struct {
|
||||
uid id.UserID
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{"@bored:test", true, "absent player, expedition it started itself — reap"},
|
||||
{"@selfstart:test", false, "absent, but they started this expedition themselves — the autopilot still holds their streak"},
|
||||
{"@returned:test", false, "bored expedition, but the player came back — don't reap someone who's playing"},
|
||||
{"@noexp:test", false, "no expedition history at all — don't reap on a guess"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isBoredomDriven(tc.uid, now); got != tc.want {
|
||||
t.Errorf("isBoredomDriven(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastExpeditionByZoneScans covers the MAX(start_date) scan — the same
|
||||
// type-affinity hazard as the COALESCE above, and the tie-breaker that stops a
|
||||
// bored adventurer grinding one zone forever.
|
||||
func TestLastExpeditionByZoneScans(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
uid := id.UserID("@hist:test")
|
||||
older := time.Now().UTC().Add(-96 * time.Hour)
|
||||
newer := time.Now().UTC().Add(-24 * time.Hour)
|
||||
|
||||
for i, e := range []struct {
|
||||
zone string
|
||||
when time.Time
|
||||
}{
|
||||
{"goblin_warrens", older},
|
||||
{"goblin_warrens", newer},
|
||||
{"crypt_valdris", older},
|
||||
} {
|
||||
if _, err := db.Get().Exec(
|
||||
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date)
|
||||
VALUES (?, ?, ?, 'complete', ?)`,
|
||||
string(uid)+string(rune('a'+i)), string(uid), e.zone, e.when); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := lastExpeditionByZone(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("lastExpeditionByZone: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d zones, want 2 — the MAX(start_date) scan is dropping rows: %v", len(got), got)
|
||||
}
|
||||
if !got[ZoneGoblinWarrens].After(got[ZoneCryptValdris]) {
|
||||
t.Errorf("goblin_warrens (%v) should be more recent than crypt_valdris (%v); "+
|
||||
"the picker relies on this to rotate zones", got[ZoneGoblinWarrens], got[ZoneCryptValdris])
|
||||
}
|
||||
}
|
||||
|
||||
// The deploy this column ships in: last_player_action_at is NULL for everyone
|
||||
// alive, and created_at is when the character was *made* — months ago for the
|
||||
// regulars. Without a seed, the very first tick after restart reads the whole
|
||||
// server as idle-since-creation and marches all of them into a dungeon, coins
|
||||
// debited, including the player who typed a command a minute before the deploy.
|
||||
func TestBootstrapSeedsClockSoADeployDoesNotEmptyTheTown(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
longAgo := now.Add(-90 * 24 * time.Hour)
|
||||
recent := now.Add(-30 * time.Minute)
|
||||
|
||||
// A regular: made months ago, played half an hour before the deploy.
|
||||
seedBoredomPlayerActive(t, "@regular:test", longAgo, &recent, nil)
|
||||
// Genuinely abandoned: made months ago, last touched the game months ago.
|
||||
seedBoredomPlayerActive(t, "@lapsed:test", longAgo, &longAgo, nil)
|
||||
|
||||
bootstrapBoredomClock()
|
||||
|
||||
uids, err := loadBoredomCandidates(now)
|
||||
if err != nil {
|
||||
t.Fatalf("loadBoredomCandidates: %v", err)
|
||||
}
|
||||
got := map[id.UserID]bool{}
|
||||
for _, u := range uids {
|
||||
got[u] = true
|
||||
}
|
||||
if got["@regular:test"] {
|
||||
t.Error("a player who acted 30 minutes ago was sent into a dungeon by the deploy")
|
||||
}
|
||||
if !got["@lapsed:test"] {
|
||||
t.Error("the abandoned character is exactly who this feature is for, and it skipped them")
|
||||
}
|
||||
if playerIsIdle("@regular:test", now) {
|
||||
t.Error("playerIsIdle still reads an active player as idle — Robbie would go silent for them")
|
||||
}
|
||||
}
|
||||
|
||||
// Same seed, but the character is already out on a bored expedition
|
||||
// (last_boredom_at set). Its own autopilot bumps last_active_at every day, so
|
||||
// re-seeding from it on each restart would hand the character a fresh idle
|
||||
// clock and boredom would never fire again.
|
||||
func TestBootstrapLeavesAlreadyBoredCharactersAlone(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
longAgo := now.Add(-90 * 24 * time.Hour)
|
||||
autopilotWrite := now.Add(-10 * time.Minute)
|
||||
wentOut := now.Add(-48 * time.Hour)
|
||||
|
||||
seedBoredomPlayerActive(t, "@bored:test", longAgo, &autopilotWrite, &wentOut)
|
||||
bootstrapBoredomClock()
|
||||
|
||||
if !playerIsIdle("@bored:test", now) {
|
||||
t.Fatal("restart re-seeded a bored character's clock off its own autopilot writes; it would never get bored again")
|
||||
}
|
||||
}
|
||||
|
||||
// seedBoredomPlayerActive seeds a row that predates the boredom column:
|
||||
// last_player_action_at NULL, with a last_active_at the autopilot/saves have
|
||||
// been keeping current.
|
||||
func seedBoredomPlayerActive(t *testing.T, uid id.UserID, created time.Time, lastActive, lastBoredom *time.Time) {
|
||||
t.Helper()
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_active_at, last_player_action_at, last_boredom_at)
|
||||
VALUES (?, ?, 1, ?, ?, NULL, ?)`,
|
||||
string(uid), "Test", created, lastActive, lastBoredom)
|
||||
if err != nil {
|
||||
t.Fatalf("seed player_meta: %v", err)
|
||||
}
|
||||
}
|
||||
95
internal/plugin/expedition_boredom_test.go
Normal file
95
internal/plugin/expedition_boredom_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// pickBoredomZone reads expedition history to break ties, so it needs a DB.
|
||||
func newBoredomTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// TestAdvActionCommandsCoverDispatch pins the boredom idle clock to the actual
|
||||
// command router.
|
||||
//
|
||||
// advActionCommands has to list every command AdventurePlugin.OnMessage routes,
|
||||
// because a command missing from the set is a command the player can use
|
||||
// without their adventurer noticing they showed up — so it sits there idle,
|
||||
// gets "bored", and walks into a dungeon while they're actively playing.
|
||||
//
|
||||
// Commands() can't be used as the source of truth: it registers 28 names for
|
||||
// the help surface and omits expedition, zone, fight, camp, extract and resume.
|
||||
// So the router is the source of truth, and this greps it.
|
||||
func TestAdvActionCommandsCoverDispatch(t *testing.T) {
|
||||
src, err := os.ReadFile("adventure.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read adventure.go: %v", err)
|
||||
}
|
||||
re := regexp.MustCompile(`p\.IsCommand\(ctx\.Body, "([a-z0-9-]+)"\)`)
|
||||
matches := re.FindAllSubmatch(src, -1)
|
||||
if len(matches) < 40 {
|
||||
t.Fatalf("only found %d IsCommand call sites in adventure.go — the regex has "+
|
||||
"drifted from the dispatcher and this test is no longer guarding anything", len(matches))
|
||||
}
|
||||
for _, m := range matches {
|
||||
name := string(m[1])
|
||||
if !advActionCommands[name] {
|
||||
t.Errorf("!%s is routed by OnMessage but missing from advActionCommands — "+
|
||||
"players using it would not reset their boredom clock", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickBoredomZoneBands walks the level bands and asserts the picker's
|
||||
// rules: easiest at-level, no raid zones while an alternative exists, and the
|
||||
// L13+ fallback into the raid.
|
||||
func TestPickBoredomZoneBands(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
cases := []struct {
|
||||
level int
|
||||
want ZoneID
|
||||
why string
|
||||
}{
|
||||
{level: 1, want: ZoneGoblinWarrens, why: "T1 band, tie broken by never-visited order"},
|
||||
{level: 5, want: ZoneForestShadows, why: "L5 is in both the T2 and T3 bands — take the easier"},
|
||||
{level: 10, want: ZoneUnderdark, why: "T4 band; underdark is multi-region but NOT a raid, so it stays eligible. Both T4 zones are unvisited here, so the tie falls through to registration order"},
|
||||
{level: 15, want: ZoneDragonsLair, why: "only raid zones in band at L15 — fallback fires, lowest tier first"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := pickBoredomZone(id.UserID("@nobody:test"), tc.level)
|
||||
if !ok {
|
||||
t.Errorf("L%d: no zone picked (%s)", tc.level, tc.why)
|
||||
continue
|
||||
}
|
||||
if got.ID != tc.want {
|
||||
t.Errorf("L%d: picked %s, want %s (%s)", tc.level, got.ID, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickBoredomZoneAvoidsRaidsWhenPossible is the rule the player actually
|
||||
// asked for, stated directly: below the endgame, a bored adventurer never gets
|
||||
// sent into party-tuned content.
|
||||
func TestPickBoredomZoneAvoidsRaidsWhenPossible(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
for level := 1; level <= 12; level++ {
|
||||
z, ok := pickBoredomZone(id.UserID("@nobody:test"), level)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if raidContentWarning(z.ID) != "" {
|
||||
t.Errorf("L%d: picked raid zone %s while a non-raid zone was in band", level, z.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
85
internal/plugin/mischief_pricing_sweep_test.go
Normal file
85
internal/plugin/mischief_pricing_sweep_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package plugin
|
||||
|
||||
// M0 pricing sweep for gogobee_mischief_plan.md — single-fight survival per
|
||||
// mischief tier, measured against the class-balance harness builds. Skip-gated:
|
||||
// it is a pricing instrument, not a regression test.
|
||||
//
|
||||
// It drives the SAME production selection code a live delivery does
|
||||
// (mischiefBracketZone + mischiefMonsters), so the fee table can't silently
|
||||
// drift away from the fight it was priced against. What it does NOT share is the
|
||||
// delivery path itself: this measures a full-HP build in a single chain, which
|
||||
// is optimistic — a real target is wounded mid-run, and may have a party. The
|
||||
// plan's M1 close-out item is to re-run this through runMischiefInterrupt.
|
||||
//
|
||||
// Run: MISCHIEF_SWEEP=1 go test ./internal/plugin -run TestMischiefPricingSweep -v
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// one trial: full-HP build fights the tier's chain; survival = won every fight.
|
||||
func mischiefTrial(p classBalanceProfile, tierKey string, rng *rand.Rand) (survived bool, endHPPct float64) {
|
||||
c := buildHarnessCharacter(p)
|
||||
zone := mischiefBracketZone(p.Level)
|
||||
chain, ambush := mischiefMonsters(tierKey, zone, rng)
|
||||
hp := c.HPMax
|
||||
for i, m := range chain {
|
||||
if ambush && i == 0 {
|
||||
hp -= clampSurpriseNick(surpriseRoundNick(m, int(zone.Tier)), hp, c.HPMax)
|
||||
}
|
||||
player := buildHarnessPlayer(c)
|
||||
if hp < c.HPMax {
|
||||
player.Stats.StartHP = hp
|
||||
}
|
||||
enemy := buildHarnessZoneEnemy(m, int(zone.Tier))
|
||||
if spell, slot, ok := pickBestDamageSpell(c); ok {
|
||||
applyHarnessSpellCast(c, spell, slot, &player.Stats, &player.Mods, &enemy.Stats)
|
||||
}
|
||||
res := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
if !res.PlayerWon {
|
||||
return false, 0
|
||||
}
|
||||
hp = res.PlayerEndHP
|
||||
}
|
||||
return true, float64(hp) / float64(c.HPMax)
|
||||
}
|
||||
|
||||
func TestMischiefPricingSweep(t *testing.T) {
|
||||
if os.Getenv("MISCHIEF_SWEEP") == "" {
|
||||
t.Skip("set MISCHIEF_SWEEP=1 to run")
|
||||
}
|
||||
const trials = 2000
|
||||
rng := rand.New(rand.NewPCG(20260713, 0x6D69736368696566))
|
||||
profiles := []classBalanceProfile{
|
||||
{Class: ClassPaladin, Level: 1},
|
||||
{Class: ClassMage, Level: 4},
|
||||
{Class: ClassMage, Level: 8, Subclass: SubclassEvocation},
|
||||
{Class: ClassFighter, Level: 12, Subclass: SubclassChampion},
|
||||
{Class: ClassRogue, Level: 20, Subclass: SubclassArcaneTrickster},
|
||||
{Class: ClassCleric, Level: 20, Subclass: SubclassLifeDomain},
|
||||
}
|
||||
fmt.Printf("%-28s %-8s %8s %10s\n", "profile", "tier", "survive%", "avgEndHP%")
|
||||
for _, p := range profiles {
|
||||
for _, tier := range mischiefTiers {
|
||||
wins := 0
|
||||
hpSum := 0.0
|
||||
for i := 0; i < trials; i++ {
|
||||
ok, hpPct := mischiefTrial(p, tier.Key, rng)
|
||||
if ok {
|
||||
wins++
|
||||
hpSum += hpPct
|
||||
}
|
||||
}
|
||||
avgHP := 0.0
|
||||
if wins > 0 {
|
||||
avgHP = hpSum / float64(wins) * 100
|
||||
}
|
||||
fmt.Printf("%-28s %-8s %7.1f%% %9.1f%%\n",
|
||||
fmt.Sprintf("%s L%d %s", p.Class, p.Level, p.Subclass), tier.Key,
|
||||
float64(wins)/trials*100, avgHP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
177
internal/plugin/pete_roster.go
Normal file
177
internal/plugin/pete_roster.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The live adventurer board pushed to Pete (gogobee_boredom_plan.md's sibling —
|
||||
// see the roster section of the Pete plan).
|
||||
//
|
||||
// Everything else we send Pete is an accomplishment: a death, a clear, a
|
||||
// milestone. Those are clippings — they read as archive the moment they land, no
|
||||
// matter how fast we deliver them. The board is the other kind of thing: state
|
||||
// that is *currently true*, which is the only thing that can make a page feel
|
||||
// alive. So it is a snapshot, pushed whole, replacing whatever Pete had.
|
||||
//
|
||||
// It is also, by design, a target list. The plan is to let people who aren't
|
||||
// even playing hire assassins and mobs against adventurers who are out in the
|
||||
// world right now — so the board carries a stable per-player token and real zone
|
||||
// depth, not just a pretty display string, and it shows the zone *live* while
|
||||
// they're still in it.
|
||||
const (
|
||||
// rosterTickInterval — how often we push. Pete's staleness window is several
|
||||
// times this, so a missed push or two is invisible; a real outage isn't.
|
||||
rosterTickInterval = 2 * time.Minute
|
||||
|
||||
// rosterPushTimeout — the push is dropped on failure, never retried (a stale
|
||||
// snapshot is a lie, and the next tick carries the truth), so it must not be
|
||||
// able to pile up.
|
||||
rosterPushTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// peteRosterTicker pushes the board to Pete forever.
|
||||
func (p *AdventurePlugin) peteRosterTicker() {
|
||||
if !peteclient.Enabled() {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(rosterTickInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if !newsEmissionOn() {
|
||||
continue // master switch off: the board goes stale on Pete and says so
|
||||
}
|
||||
p.pushRoster()
|
||||
}
|
||||
}
|
||||
|
||||
// rosterPushOK tracks the last push's outcome so we can log the transitions and
|
||||
// nothing else. A push every 2 minutes forever is far too noisy to log at INFO,
|
||||
// but total silence is worse: a ticker that is succeeding quietly looks exactly
|
||||
// like one that never started, and that ambiguity already cost an operator a
|
||||
// wrong-turn debug during the first deploy. So: say something the first time it
|
||||
// works, say something when it breaks, say something when it recovers.
|
||||
var rosterPushOK bool
|
||||
|
||||
func (p *AdventurePlugin) pushRoster() {
|
||||
snap, err := buildRosterSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
slog.Error("roster: build snapshot failed", "err", err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := peteclient.PushRoster(ctx, snap); err != nil {
|
||||
// The failure itself is not alarming — the next tick retries by simply
|
||||
// being a fresher snapshot, and if we stay down Pete's board correctly
|
||||
// stops claiming to be live. Only the *transition* is worth a line.
|
||||
if rosterPushOK {
|
||||
slog.Warn("roster: push failed, board will go stale on Pete", "err", err)
|
||||
} else {
|
||||
slog.Debug("roster: push failed, dropping snapshot", "err", err)
|
||||
}
|
||||
rosterPushOK = false
|
||||
return
|
||||
}
|
||||
|
||||
if !rosterPushOK {
|
||||
slog.Info("roster: board accepted by Pete — live adventurer board is publishing",
|
||||
"adventurers", len(snap.Adventurers))
|
||||
rosterPushOK = true
|
||||
}
|
||||
}
|
||||
|
||||
// buildRosterSnapshot assembles the complete board.
|
||||
//
|
||||
// Complete is the contract: Pete *replaces* its board with this, so anyone we
|
||||
// omit drops off the public page. That is exactly how the opt-out is enforced —
|
||||
// an opted-out player is simply never in the payload, rather than being sent and
|
||||
// anonymized. A standing row showing class + level + zone is trivially
|
||||
// re-identifiable (there is one level-14 cleric), so "an adventurer" would have
|
||||
// been a fig leaf; absence is the only honest option.
|
||||
func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
|
||||
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix()}
|
||||
|
||||
// Both DATETIME columns selected raw and folded in Go — NOT COALESCE()'d in
|
||||
// SQL. modernc.org/sqlite rebuilds a time.Time from the column's *declared*
|
||||
// type, and COALESCE() erases that affinity: the value comes back a string
|
||||
// and the Scan fails. Same trap playerIsIdle documents.
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT user_id, last_player_action_at, created_at
|
||||
FROM player_meta
|
||||
WHERE alive = 1`)
|
||||
if err != nil {
|
||||
return snap, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type player struct {
|
||||
uid id.UserID
|
||||
lastAction *time.Time
|
||||
}
|
||||
var players []player
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
var lastAction, created *time.Time
|
||||
if err := rows.Scan(&uid, &lastAction, &created); err != nil {
|
||||
return snap, err
|
||||
}
|
||||
if lastAction == nil {
|
||||
lastAction = created
|
||||
}
|
||||
players = append(players, player{id.UserID(uid), lastAction})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return snap, err
|
||||
}
|
||||
|
||||
for _, pl := range players {
|
||||
if isNewsOptedOut(pl.uid) {
|
||||
continue
|
||||
}
|
||||
c, err := LoadDnDCharacter(pl.uid)
|
||||
if err != nil || c == nil || c.PendingSetup {
|
||||
continue // no character to show; a half-made one has no name yet
|
||||
}
|
||||
name := charName(pl.uid)
|
||||
if name == "" {
|
||||
continue // never fall back to a Matrix handle on a public page
|
||||
}
|
||||
|
||||
e := peteclient.RosterEntry{
|
||||
// Stable per-player board token: salted, so it can't be recomputed
|
||||
// from the handle, and distinct from every event token, so the board
|
||||
// doesn't become the key that links a player's dispatches together.
|
||||
Token: eventToken(pl.uid, "roster"),
|
||||
Name: name,
|
||||
Level: c.Level,
|
||||
ClassRace: classRaceLabel(c),
|
||||
Status: "idle",
|
||||
}
|
||||
|
||||
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
e.Status = "expedition"
|
||||
e.Zone = zone.Display
|
||||
e.Day = exp.CurrentDay
|
||||
if IsMultiRegionZone(exp.ZoneID) {
|
||||
if r, ok := CurrentRegion(exp); ok {
|
||||
e.Region = r.Name
|
||||
}
|
||||
}
|
||||
} else if pl.lastAction != nil {
|
||||
if h := int(now.Sub(*pl.lastAction).Hours()); h > 0 {
|
||||
e.IdleHours = h
|
||||
}
|
||||
}
|
||||
snap.Adventurers = append(snap.Adventurers, e)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
127
internal/plugin/pete_roster_test.go
Normal file
127
internal/plugin/pete_roster_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// seedRosterPlayer puts a real, playable character on the board: a player_meta
|
||||
// row (which carries the idle clock and the display name) plus a confirmed
|
||||
// dnd_character. Real rows on purpose — the snapshot query reads two DATETIME
|
||||
// columns, and the modernc affinity trap only fires against actual stored
|
||||
// values, never against a hand-built struct.
|
||||
func seedRosterPlayer(t *testing.T, uid id.UserID, name string, created, lastAction *time.Time) {
|
||||
t.Helper()
|
||||
if _, err := db.Get().Exec(
|
||||
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at)
|
||||
VALUES (?, ?, 1, ?, ?)`,
|
||||
string(uid), name, created, lastAction); err != nil {
|
||||
t.Fatalf("seed player_meta: %v", err)
|
||||
}
|
||||
if err := SaveDnDCharacter(&DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||
STR: 15, DEX: 14, CON: 13, INT: 12, WIS: 10, CHA: 8,
|
||||
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
|
||||
PendingSetup: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed character: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterSnapshotReadsTheClock is the scan-affinity guard. The snapshot
|
||||
// selects last_player_action_at and created_at as declared DATETIME columns and
|
||||
// folds them in Go; a COALESCE() in the SQL would erase the affinity, the Scan
|
||||
// would fail, and buildRosterSnapshot would return an error — publishing an
|
||||
// empty board (every adventurer vanishes from the public page) rather than
|
||||
// anything obviously broken.
|
||||
func TestRosterSnapshotReadsTheClock(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-50 * time.Hour)
|
||||
recent := now.Add(-3 * time.Hour)
|
||||
|
||||
seedRosterPlayer(t, "@quiet:test", "Quack", &old, &old)
|
||||
seedRosterPlayer(t, "@recent:test", "Josie", &old, &recent)
|
||||
// Never acted at all: the fold falls through to created_at.
|
||||
seedRosterPlayer(t, "@never:test", "Camcast", &old, nil)
|
||||
|
||||
snap, err := buildRosterSnapshot(now)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||
}
|
||||
if len(snap.Adventurers) != 3 {
|
||||
t.Fatalf("board has %d adventurers, want 3", len(snap.Adventurers))
|
||||
}
|
||||
if snap.SnapshotAt != now.Unix() {
|
||||
t.Errorf("snapshot_at = %d, want %d", snap.SnapshotAt, now.Unix())
|
||||
}
|
||||
|
||||
byName := map[string]int{}
|
||||
for _, a := range snap.Adventurers {
|
||||
byName[a.Name] = a.IdleHours
|
||||
if a.Status != "idle" {
|
||||
t.Errorf("%s status = %q, want idle (nobody is on an expedition)", a.Name, a.Status)
|
||||
}
|
||||
if a.Token == "" {
|
||||
t.Errorf("%s has no board token", a.Name)
|
||||
}
|
||||
}
|
||||
if got := byName["Quack"]; got != 50 {
|
||||
t.Errorf("Quack idle hours = %d, want 50 — the clock didn't survive the scan", got)
|
||||
}
|
||||
if got := byName["Josie"]; got != 3 {
|
||||
t.Errorf("Josie idle hours = %d, want 3", got)
|
||||
}
|
||||
if got := byName["Camcast"]; got != 50 {
|
||||
t.Errorf("Camcast idle hours = %d, want 50 (fell through to created_at)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterOmitsOptedOut is the privacy contract. Pete *replaces* its whole
|
||||
// board with this payload, so omission is what enforces the opt-out. Anonymizing
|
||||
// instead would be a fig leaf: a standing row with class + level + zone names the
|
||||
// player anyway.
|
||||
func TestRosterOmitsOptedOut(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-30 * time.Hour)
|
||||
|
||||
seedRosterPlayer(t, "@shown:test", "Josie", &old, &old)
|
||||
seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old)
|
||||
setNewsOptout("@hidden:test", true)
|
||||
|
||||
snap, err := buildRosterSnapshot(now)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||
}
|
||||
if len(snap.Adventurers) != 1 {
|
||||
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
|
||||
}
|
||||
if snap.Adventurers[0].Name != "Quack" && snap.Adventurers[0].Name != "Josie" {
|
||||
t.Fatalf("unexpected adventurer %q", snap.Adventurers[0].Name)
|
||||
}
|
||||
if snap.Adventurers[0].Name == "Quack" {
|
||||
t.Error("an opted-out player is on the public board")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterTokenIsNotAnEventToken: the board token must not be one of the
|
||||
// player's event tokens, or a standing row would become the key that links all
|
||||
// their dispatches back together — the exact unlinkability eventToken exists to
|
||||
// provide.
|
||||
func TestRosterTokenIsNotAnEventToken(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
uid := id.UserID("@josie:test")
|
||||
|
||||
board := eventToken(uid, "roster")
|
||||
if board == eventToken(uid, "arrival") || board == eventToken(uid, "death:crypt:1") {
|
||||
t.Error("board token collides with an event token — the board would deanonymize the feed")
|
||||
}
|
||||
if board != eventToken(uid, "roster") {
|
||||
t.Error("board token not stable — the row would churn identity every snapshot")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user