mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
42 Commits
57a0ea90f2
...
mischief-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94077a47ae | ||
|
|
8fc5a82b83 | ||
|
|
2ab6e7843a | ||
|
|
ced75786b9 | ||
|
|
1cbd68a718 | ||
|
|
e377e0c85c | ||
|
|
7c379b298c | ||
|
|
a533e106c2 | ||
|
|
2482433896 | ||
|
|
da398cf674 | ||
|
|
e98029e6ac | ||
|
|
064ecb1848 | ||
|
|
bae83271fe | ||
|
|
c9128fb0d6 | ||
|
|
055f07d3c0 | ||
|
|
27b9de5936 | ||
|
|
01c2cb2f0b | ||
|
|
d538f91cf7 | ||
|
|
f4a4c0d30b | ||
|
|
1634bb1970 | ||
|
|
ae7ff38996 | ||
|
|
b42beec348 | ||
|
|
0c4c4757d3 | ||
|
|
fedd357a29 | ||
|
|
59319ede9d | ||
|
|
3f4b4ece5c | ||
|
|
a6f1de4e74 | ||
|
|
1a47a2fdee | ||
|
|
aaa45eab14 | ||
|
|
38a3693832 | ||
|
|
401b17c3bb | ||
|
|
3026fe6012 | ||
|
|
e2c0c3359b | ||
|
|
127d252982 | ||
|
|
c3e122694b | ||
|
|
cc165bed1f | ||
|
|
81b2359109 | ||
|
|
3103b519fb | ||
|
|
c37c95a3e3 | ||
|
|
9b6c1ff9a4 | ||
|
|
aab7a7bad0 | ||
|
|
fd7803b13c |
@@ -46,7 +46,9 @@ func main() {
|
||||
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
||||
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
|
||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
||||
@@ -60,6 +62,7 @@ func main() {
|
||||
|
||||
party = flag.Int("party", 1, "seat a party of N (1 = solo, the historical path). Followers are invited on Day 1 and buy their own supplies")
|
||||
partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)")
|
||||
companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.")
|
||||
|
||||
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
|
||||
)
|
||||
@@ -86,11 +89,51 @@ func main() {
|
||||
includeLog = *logFlag
|
||||
}
|
||||
})
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses)
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
||||
if *realUser != "" {
|
||||
if *dataDir == "" {
|
||||
fail("-real-user requires -data pointing at a dir containing a populated gogobee.db")
|
||||
}
|
||||
if *party != 1 {
|
||||
fail("-real-user does not support -party yet (would need every seat to be a real, tier-eligible char)")
|
||||
}
|
||||
runReal(*realUser, *zone, *dataDir, *bank, *cap, *days, *logFlag)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers, *companion)
|
||||
}
|
||||
|
||||
// runReal drives an existing character loaded from dataDir's gogobee.db through
|
||||
// a real expedition. dataDir should be a COPY of prod — db.Init runs additive
|
||||
// migrations against it, and the run mutates HP/coin/inventory. Never point this
|
||||
// at the live prod file or at ./data.
|
||||
func runReal(userTag, zone, dataDir string, bank float64, cap, days int, includeLog bool) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
fail("init runner:", err)
|
||||
}
|
||||
defer runner.Close()
|
||||
|
||||
uid := id.UserID(userTag)
|
||||
c, err := runner.PrepareRealCharacter(uid, bank)
|
||||
if err != nil {
|
||||
fail("prepare real character:", err)
|
||||
}
|
||||
res, err := runner.RunExpedition(uid, plugin.ZoneID(zone), cap, days)
|
||||
if res != nil {
|
||||
res.Class = string(c.Class) // real subclass/race aren't in SimResult; class is the useful key
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
if err != nil {
|
||||
fail("run:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// followerClasses expands -party / -party-classes into the class of each
|
||||
@@ -119,7 +162,7 @@ func followerClasses(leaderClass string, party int, spec string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string) {
|
||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string, companion string) {
|
||||
dir := dataDir
|
||||
if dir == "" {
|
||||
var err error
|
||||
@@ -130,7 +173,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
|
||||
defer os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers)
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers, companion)
|
||||
if err != nil {
|
||||
if res != nil {
|
||||
if !includeLog {
|
||||
@@ -157,7 +200,7 @@ type matrixJob struct {
|
||||
rep int
|
||||
}
|
||||
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses string) {
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) {
|
||||
cs := splitNonEmpty(classes)
|
||||
ls := parseLevels(levels)
|
||||
zs := splitNonEmpty(zones)
|
||||
@@ -188,7 +231,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < jobs; i++ {
|
||||
wg.Add(1)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
|
||||
}
|
||||
go func() {
|
||||
for _, j := range work {
|
||||
@@ -207,7 +250,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
||||
}
|
||||
}
|
||||
|
||||
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses string) {
|
||||
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string) {
|
||||
defer wg.Done()
|
||||
for j := range in {
|
||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
||||
@@ -233,6 +276,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
||||
if partyClasses != "" {
|
||||
args = append(args, "-party-classes", partyClasses)
|
||||
}
|
||||
if companion != "" {
|
||||
args = append(args, "-companion", companion)
|
||||
}
|
||||
if trace {
|
||||
args = append(args, "-trace")
|
||||
}
|
||||
@@ -272,7 +318,7 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
||||
}
|
||||
}
|
||||
|
||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string) (*plugin.SimResult, error) {
|
||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string, companion string) (*plugin.SimResult, error) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init runner: %w", err)
|
||||
@@ -296,6 +342,7 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
|
||||
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
|
||||
members = append(members, muid)
|
||||
}
|
||||
runner.Companion = companion
|
||||
return runner.RunPartyExpedition(uid, members, zone, cap, days)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -378,6 +390,64 @@ func runMigrations(d *sql.DB) error {
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_armor_tier INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_flags_json TEXT NOT NULL DEFAULT '{}'`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_level_10_date TEXT NOT NULL DEFAULT ''`,
|
||||
// N5/D1 the Hollow King campaign (gogobee_engagement_plan.md §D1). Found
|
||||
// journal pages are a bitmask (bit i == page i+1 discovered), granted one at
|
||||
// a time from elite kills and secret rooms. A single INTEGER rather than a
|
||||
// rows table: pages are static content, only found/not-found is per-player,
|
||||
// and grants are an atomic bitwise-OR so a page can't be lost to a stale
|
||||
// character save. DEFAULT 0 == "no pages found", correct for every
|
||||
// pre-existing row, so no bootstrap backfill.
|
||||
`ALTER TABLE player_meta ADD COLUMN journal_pages INTEGER NOT NULL DEFAULT 0`,
|
||||
// N5/D1c the Hollow King finale (gogobee_engagement_plan.md §D1). Set once,
|
||||
// the first time a player closes the account — the reward (unique title +
|
||||
// one Legendary) drops only on that first clear; later rematches are
|
||||
// flavour-only. Written by a dedicated atomic UPDATE, never the bulk
|
||||
// character save, so a monotonic false→true flag can't be clobbered.
|
||||
// DEFAULT 0 correct for every pre-existing row, so no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`,
|
||||
// N7/B2 Renown (gogobee_engagement_plan.md §B2). At the L20 cap, overflow
|
||||
// XP that grantDnDXP used to drop instead accumulates here as cumulative
|
||||
// prestige XP. renown_xp is monotonic and written by an atomic += (the
|
||||
// journal_pages pattern), never the bulk character save; renown_level is
|
||||
// DERIVED from it (renown_xp / renownXPPerLevel) rather than stored, so
|
||||
// there is no read-modify-write race and no second field to disagree.
|
||||
// DEFAULT 0 == "no renown", correct for every pre-existing row, no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
|
||||
// The hireable companion (pete_adventure_news_plan.md, "Pete as a
|
||||
// character"). Pete's roster seat records the class and level he was
|
||||
// hired at, so a party that levels mid-expedition doesn't silently
|
||||
// re-roll its hireling into a different class three rooms in. Both are
|
||||
// empty/0 on every player row and are only ever read for the one seat
|
||||
// where isCompanionSeat holds — a player's row never consults them.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_class TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_level INTEGER NOT NULL DEFAULT 0`,
|
||||
|
||||
// The companion's spell-slot ledger, "used" per slot level, as a compact
|
||||
// CSV of six ints (index 0 unused; cantrips cost nothing).
|
||||
//
|
||||
// It has to live on the *expedition*, next to the class and level it is a
|
||||
// pool for. A human caster's slots are dnd_spell_slots rows that persist
|
||||
// across every fight of the run and only come back at camp, so rationing a
|
||||
// pool across a 30-room day IS the caster's game. The first cut of this
|
||||
// parked the companion's ledger on his combat seat instead — and a seat is
|
||||
// per-session, so he walked into every single fight with a full pool. The
|
||||
// sim caught it: a level-penalized, gearless hireling out-cleared a human
|
||||
// cleric of the leader's own level by 15pp.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_slots_used TEXT NOT NULL DEFAULT ''`,
|
||||
|
||||
// The companion's body, carried across the run. -1 means "unset" — he is
|
||||
// at full, which is what a fresh hire is.
|
||||
//
|
||||
// He used to re-seat at full max HP for *every* fight, because he has no
|
||||
// dnd_character row for his HP to persist onto and the close-out skipped
|
||||
// him ("he arrives fresh next time"). That is an infinite body: a player
|
||||
// bleeds across a 30-room run and only heals at camp, while the hireling
|
||||
// soaked half the incoming damage and reset. Measured, it is most of why a
|
||||
// gearless, level-penalized hireling out-cleared a human cleric of the
|
||||
// leader's own level — his party fled 5 runs out of 640 where the human
|
||||
// party fled 56.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -593,6 +663,15 @@ func RunMaintenance() {
|
||||
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Pete adventure-news queue — reap delivered rows (kept only for
|
||||
// idempotency); undelivered rows stay so the sender can retry/park them.
|
||||
{"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}},
|
||||
// ...and reap permanently-parked rows: the sender exhausts its retries
|
||||
// within a few hours, so anything still unsent after 30 days is dead
|
||||
// weight the drain query already skips — drop it so a durable outage
|
||||
// can't accrete rows forever.
|
||||
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Rate limits — purge entries older than today
|
||||
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
||||
|
||||
@@ -924,6 +1003,46 @@ CREATE TABLE IF NOT EXISTS shade_optout (
|
||||
user_id TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
-- Pete adventure-news seam: durable outbound queue of game-event facts sent to
|
||||
-- the Pete news bot. Emit enqueues (INSERT OR IGNORE on guid = idempotency); a
|
||||
-- background sender drains rows where sent_at IS NULL. Keyed on the fact guid so
|
||||
-- retries and duplicate emits collapse to one row.
|
||||
CREATE TABLE IF NOT EXISTS pete_emit_queue (
|
||||
guid TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||
sent_at INTEGER
|
||||
);
|
||||
|
||||
-- Players who opted out of being named in Pete's adventure news. Enforced at
|
||||
-- emit time (anonymize, never delete). Mirrors shade_optout.
|
||||
CREATE TABLE IF NOT EXISTS news_optout (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
opted_out_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Realm-first ledger for adventure news: the first (kind,target) occurrence
|
||||
-- across all players. INSERT OR IGNORE returns rows-affected>0 exactly once, so
|
||||
-- the first clear of a zone/boss fires as a PRIORITY realm-first and everyone
|
||||
-- after as a BULLETIN personal clear. Seeded by the cold-start backfill so a
|
||||
-- fresh deploy doesn't re-flag historically-cleared content as first-ever.
|
||||
CREATE TABLE IF NOT EXISTS news_realm_firsts (
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
first_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (kind, target)
|
||||
);
|
||||
|
||||
-- Durable runtime config for adventure news (e.g. the emission kill-switch).
|
||||
-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after
|
||||
-- 7 days, which would silently revert an operator's !news off back to on.
|
||||
CREATE TABLE IF NOT EXISTS news_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Birthdays
|
||||
CREATE TABLE IF NOT EXISTS birthdays (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
@@ -1595,12 +1714,64 @@ CREATE TABLE IF NOT EXISTS adventure_rival_challenges (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at);
|
||||
|
||||
-- Duels (N6/C2): player-initiated, staked, no-death combat bouts. Both stakes
|
||||
-- are escrowed while the row lives; expiry/decline refunds the challenger. W/L
|
||||
-- history reuses adventure_rival_records. No bootstrap — absent row == no duel.
|
||||
CREATE TABLE IF NOT EXISTS adventure_duel_challenges (
|
||||
challenge_id TEXT PRIMARY KEY,
|
||||
challenger_id TEXT NOT NULL,
|
||||
challenged_id TEXT NOT NULL,
|
||||
stake INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_duel_challenges_user ON adventure_duel_challenges(challenged_id, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_duel_challenges_expiry ON adventure_duel_challenges(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community_pot (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
balance INTEGER NOT NULL DEFAULT 0,
|
||||
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,
|
||||
@@ -2200,6 +2371,73 @@ CREATE TABLE IF NOT EXISTS expedition_invite (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_expedition_invite_user
|
||||
ON expedition_invite(user_id);
|
||||
|
||||
-- ── N6/D3 — the Shadow: a simulated rival adventurer ──────────────────────
|
||||
-- One row per player, born lazily the first midnight after their character
|
||||
-- exists. The Shadow "runs" the same zone progression on a simulated schedule
|
||||
-- (~1.3x the player's own clear pace), advanced once per UTC day by the
|
||||
-- midnight ticker. Pure theatre: no combat, no punishment — only race
|
||||
-- pressure surfaced in the morning briefing and a payoff at each zone clear.
|
||||
--
|
||||
-- Absent == the player has no Shadow yet, which is true of every row that
|
||||
-- existed before N6, so there is nothing to backfill. The ticker mints the
|
||||
-- row on first advance. This table is deliberately NOT part of the
|
||||
-- player_meta save fan-out: the ticker owns it, so a character save can never
|
||||
-- clobber the Shadow's advance (the same isolation journal_pages earns by
|
||||
-- being grant-only, made structural here).
|
||||
--
|
||||
-- name: the rival's proper name, seeded deterministically from the
|
||||
-- player's display name at birth.
|
||||
-- progress: cumulative zone-units the Shadow has run (fractional, so a
|
||||
-- slow player still sees it creep between clears).
|
||||
-- zones_cleared: floor(progress) at the last advance — the committed count,
|
||||
-- stored so the next advance can tell which zones were newly
|
||||
-- finished.
|
||||
-- pending_mask: zones (by progression index bit) the Shadow cleared before
|
||||
-- the player did — a journal page waits in each until the
|
||||
-- player clears that zone's boss.
|
||||
-- crowed_mask: zones the player beat the Shadow to and has already been
|
||||
-- crowed a bonus for — set-once, so re-running a zone the
|
||||
-- Shadow hasn't reached can't farm the crow XP.
|
||||
-- day_counter: the Shadow's own day count, for flavour variety.
|
||||
-- last_advanced: UTC date of the last advance; the per-day idempotency guard.
|
||||
CREATE TABLE IF NOT EXISTS adventure_shadow (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
progress REAL NOT NULL DEFAULT 0,
|
||||
zones_cleared INTEGER NOT NULL DEFAULT 0,
|
||||
pending_mask INTEGER NOT NULL DEFAULT 0,
|
||||
crowed_mask INTEGER NOT NULL DEFAULT 0,
|
||||
day_counter INTEGER NOT NULL DEFAULT 0,
|
||||
last_advanced TEXT NOT NULL DEFAULT '',
|
||||
born_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- World boss (N6/C3) — the monthly communal "Siege". One event at a time is
|
||||
-- live (status='active'); the ticker resolves it at hp_current<=0 (defeated) or
|
||||
-- after ends_at (survived). History rows are retained (autoincrement id), so
|
||||
-- world_boss_contrib keys on boss_id. Deliberately its own tables, outside the
|
||||
-- player_meta save fan-out, so a character save can't clobber the shared pool.
|
||||
CREATE TABLE IF NOT EXISTS world_boss (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL DEFAULT 5,
|
||||
hp_max INTEGER NOT NULL,
|
||||
hp_current INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
starts_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ends_at DATETIME NOT NULL,
|
||||
resolved_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS world_boss_contrib (
|
||||
boss_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
fights INTEGER NOT NULL DEFAULT 0,
|
||||
damage INTEGER NOT NULL DEFAULT 0,
|
||||
last_fight_date TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (boss_id, user_id)
|
||||
);
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
275
internal/peteclient/client.go
Normal file
275
internal/peteclient/client.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Package peteclient is gogobee's outbound seam to the Pete news bot.
|
||||
//
|
||||
// gogobee is the source of game-event *facts* and owns delivery; Pete owns
|
||||
// voice, authoring, and publishing. This package carries structured facts (not
|
||||
// prose) to Pete's ingest endpoint over the tailnet, bearer-authed.
|
||||
//
|
||||
// Delivery is durable: Emit writes the fact to a SQLite queue and returns
|
||||
// immediately, so a game-loop hook never blocks on the network and a Pete
|
||||
// restart loses nothing. A background sender drains the queue with retry.
|
||||
// Idempotency is on the fact GUID, so retries and duplicate emits are no-ops.
|
||||
package peteclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// Fact is the flat, pre-sanitized payload gogobee POSTs to Pete. Names must be
|
||||
// character names only (never Matrix handles); Actors is the allow-list of the
|
||||
// only names permitted to appear in Pete's rendered output. See
|
||||
// pete_adventure_news_voice.md for the field contract.
|
||||
type Fact struct {
|
||||
GUID string `json:"guid"` // stable idempotency key, e.g. "death:<token>:<ts>"; prefix == event_type
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Opponent string `json:"opponent,omitempty"`
|
||||
Boss string `json:"boss,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Stakes string `json:"stakes,omitempty"`
|
||||
ClassRace string `json:"class_race,omitempty"`
|
||||
Milestone string `json:"milestone,omitempty"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
||||
}
|
||||
|
||||
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
|
||||
// queued), matching the FEATURE_PETE_NEWS master switch that kills emission at
|
||||
// the source.
|
||||
type Config struct {
|
||||
IngestURL string
|
||||
Token string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Client is the transport half. It is a package singleton initialized by Init,
|
||||
// so emit hooks scattered across plugins (and free functions like
|
||||
// markAdventureDead) can call Emit without threading a handle through.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
var std *Client
|
||||
|
||||
// Tuning for the background sender.
|
||||
const (
|
||||
senderTick = 15 * time.Second
|
||||
senderBatch = 20
|
||||
maxAttempts = 8 // ~ up to a few hours of backoff, then park
|
||||
backoffBase = 30 * time.Second
|
||||
backoffCapSec = 3600
|
||||
sendTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// Init wires the singleton from the environment. Mirrors the per-plugin config
|
||||
// pattern (email_nag.go): PETE_INGEST_URL, PETE_INGEST_TOKEN, FEATURE_PETE_NEWS.
|
||||
func Init() {
|
||||
cfg := Config{
|
||||
IngestURL: strings.TrimRight(os.Getenv("PETE_INGEST_URL"), "/"),
|
||||
Token: os.Getenv("PETE_INGEST_TOKEN"),
|
||||
Enabled: strings.EqualFold(os.Getenv("FEATURE_PETE_NEWS"), "true"),
|
||||
}
|
||||
if cfg.Enabled && (cfg.IngestURL == "" || cfg.Token == "") {
|
||||
slog.Warn("peteclient: FEATURE_PETE_NEWS=true but PETE_INGEST_URL/PETE_INGEST_TOKEN unset — disabling")
|
||||
cfg.Enabled = false
|
||||
}
|
||||
std = &Client{cfg: cfg, http: &http.Client{Timeout: sendTimeout}}
|
||||
if cfg.Enabled {
|
||||
slog.Info("peteclient: adventure news emission enabled", "ingest", cfg.IngestURL)
|
||||
} else {
|
||||
slog.Info("peteclient: adventure news emission disabled (set FEATURE_PETE_NEWS=true)")
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether emission is on. Callers can skip building an
|
||||
// (expensive) fact when it would be dropped anyway.
|
||||
func Enabled() bool { return std != nil && std.cfg.Enabled }
|
||||
|
||||
// Emit durably queues a fact for delivery to Pete. It never blocks on the
|
||||
// network. A no-op (but safe) when the seam is disabled or the GUID was already
|
||||
// queued — idempotency is on the GUID primary key.
|
||||
func Emit(f Fact) {
|
||||
if !Enabled() {
|
||||
return
|
||||
}
|
||||
if f.GUID == "" {
|
||||
slog.Error("peteclient: refusing to queue fact with empty guid", "event_type", f.EventType)
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
|
||||
return
|
||||
}
|
||||
// OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped.
|
||||
db.Exec("pete emit enqueue",
|
||||
`INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at)
|
||||
VALUES (?, ?, unixepoch(), 0, 0)`,
|
||||
f.GUID, string(payload))
|
||||
}
|
||||
|
||||
// StartSender launches the background drain loop. It runs until ctx is
|
||||
// canceled. Safe to call when disabled — it simply idles.
|
||||
func StartSender(ctx context.Context) {
|
||||
if std == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(senderTick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if std.cfg.Enabled {
|
||||
std.drain(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// drain sends up to senderBatch due rows, one at a time.
|
||||
func (c *Client) drain(ctx context.Context) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT guid, payload FROM pete_emit_queue
|
||||
WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch()
|
||||
ORDER BY created_at LIMIT ?`,
|
||||
maxAttempts, senderBatch)
|
||||
if err != nil {
|
||||
slog.Error("peteclient: drain query", "err", err)
|
||||
return
|
||||
}
|
||||
type item struct{ guid, payload string }
|
||||
var batch []item
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.guid, &it.payload); err != nil {
|
||||
slog.Error("peteclient: drain scan", "err", err)
|
||||
continue
|
||||
}
|
||||
batch = append(batch, it)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, it := range batch {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := c.send(ctx, []byte(it.payload)); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// Shutdown canceled the in-flight send — Pete didn't reject
|
||||
// anything. Don't burn a durable retry attempt; the row is picked
|
||||
// up on the next boot's drain.
|
||||
return
|
||||
}
|
||||
db.Exec("pete emit retry",
|
||||
`UPDATE pete_emit_queue
|
||||
SET attempts = attempts + 1, next_attempt_at = unixepoch() + ?
|
||||
WHERE guid = ?`,
|
||||
backoffSec(it.guid), it.guid)
|
||||
slog.Warn("peteclient: emit failed, will retry", "guid", it.guid, "err", err)
|
||||
continue
|
||||
}
|
||||
db.Exec("pete emit sent",
|
||||
`UPDATE pete_emit_queue SET sent_at = unixepoch() WHERE guid = ?`, it.guid)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("pete ingest status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backoffSec computes the retry delay for a row. It re-reads the current attempt
|
||||
// count so the delay grows geometrically without needing it passed in.
|
||||
func backoffSec(guid string) int {
|
||||
var attempts int
|
||||
_ = db.Get().QueryRow(`SELECT attempts FROM pete_emit_queue WHERE guid = ?`, guid).Scan(&attempts)
|
||||
// attempts is the count *before* this failure's increment; delay off it.
|
||||
delay := int(backoffBase.Seconds()) << attempts
|
||||
if delay > backoffCapSec {
|
||||
delay = backoffCapSec
|
||||
}
|
||||
return delay
|
||||
}
|
||||
91
internal/peteclient/client_test.go
Normal file
91
internal/peteclient/client_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package peteclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// TestEmitDrainRoundTrip proves the durable path: Emit queues a fact, the sender
|
||||
// POSTs it to Pete with bearer auth, and the row is marked sent (so it won't
|
||||
// re-send), while a duplicate GUID collapses to one delivery.
|
||||
func TestEmitDrainRoundTrip(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var got []Fact
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/ingest/adventure" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var f Fact
|
||||
_ = json.Unmarshal(body, &f)
|
||||
mu.Lock()
|
||||
got = append(got, f)
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
std = &Client{cfg: Config{IngestURL: srv.URL, Token: "tok", Enabled: true}, http: srv.Client()}
|
||||
|
||||
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1})
|
||||
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) // dup guid
|
||||
|
||||
std.drain(context.Background())
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("delivered %d facts, want 1 (dup should collapse)", len(got))
|
||||
}
|
||||
if got[0].Subject != "Brannigan" {
|
||||
t.Errorf("subject = %q", got[0].Subject)
|
||||
}
|
||||
if gotAuth != "Bearer tok" {
|
||||
t.Errorf("auth header = %q", gotAuth)
|
||||
}
|
||||
|
||||
var pending int
|
||||
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE sent_at IS NULL`).Scan(&pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Errorf("%d rows still pending after successful drain", pending)
|
||||
}
|
||||
|
||||
// A second drain re-sends nothing.
|
||||
std.drain(context.Background())
|
||||
if len(got) != 1 {
|
||||
t.Errorf("re-drained a sent row: %d deliveries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitDisabledNoQueue: when disabled, Emit is a durable no-op.
|
||||
func TestEmitDisabledNoQueue(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
std = &Client{cfg: Config{Enabled: false}, http: http.DefaultClient}
|
||||
Emit(Fact{GUID: "disabled-guid", EventType: "death", OccurredAt: 1})
|
||||
var n int
|
||||
// db.Init is a process singleton, so this may share state with other tests;
|
||||
// scope the check to this fact's guid.
|
||||
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE guid = 'disabled-guid'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("disabled Emit queued %d rows, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -101,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
||||
_, err := d.Exec(
|
||||
res, err := d.Exec(
|
||||
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
||||
string(userID), achievementID,
|
||||
)
|
||||
@@ -109,7 +110,43 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st
|
||||
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
||||
return
|
||||
}
|
||||
// Only a genuinely new grant is news; a re-check conflict (rows==0) is not.
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return
|
||||
}
|
||||
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
|
||||
p.emitMilestoneNews(d, userID, achievementID)
|
||||
}
|
||||
|
||||
// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements
|
||||
// only — rarity-gated to a single holder (per the news selection thresholds) so
|
||||
// routine unlocks don't flood the feed. Character name only; no-op unless the
|
||||
// seam is enabled.
|
||||
func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) {
|
||||
var holders int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 {
|
||||
return
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
label := achievementID
|
||||
for _, a := range p.achievements {
|
||||
if a.ID == achievementID {
|
||||
label = a.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(userID, achievementID), achievementID),
|
||||
EventType: "milestone",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Milestone: label,
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||
@@ -1206,9 +1243,39 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return false
|
||||
},
|
||||
},
|
||||
// N7/B4 — the Renown wing (gogobee_engagement_plan.md §B4). Passive checks
|
||||
// against the derived Renown level (N7/B2); no event hook needed.
|
||||
{
|
||||
ID: "renown_1", Name: "Beyond the Cap", Description: "Earned your first level of Renown. The story was supposed to be over.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 1) },
|
||||
},
|
||||
{
|
||||
ID: "renown_5", Name: "Storied", Description: "Reached Renown 5. They tell versions of your runs that never happened.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "renown_10", Name: "Fabled", Description: "Reached Renown 10. Somewhere a bard is getting rich off your name.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 10) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// renownAtLeast reports whether the player's derived Renown level (N7/B2) has
|
||||
// reached level, reading player_meta.renown_xp through the passed handle.
|
||||
func renownAtLeast(d *sql.DB, userID id.UserID, level int) bool {
|
||||
var xp int
|
||||
if err := d.QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp); err != nil {
|
||||
return false
|
||||
}
|
||||
return renownLevelFor(xp) >= level
|
||||
}
|
||||
|
||||
// ── Expedition achievement helpers ──────────────────────────────────────────
|
||||
|
||||
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
|
||||
|
||||
@@ -169,6 +169,8 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
{Name: "rest", Description: "Rest up (`short`: quick breather, 1h cooldown — `long`: full night, 24h, needs housing or inn)", Usage: "!rest short|long", Category: "Games"},
|
||||
{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"},
|
||||
@@ -183,6 +185,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
|
||||
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
|
||||
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
|
||||
{Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +249,10 @@ func (p *AdventurePlugin) Init() error {
|
||||
// arrival roll. Both idempotent via JobCompleted gates.
|
||||
bootstrapCasterSpellBackfill()
|
||||
bootstrapGrantStarterPet()
|
||||
// Pete adventure-news cold-start: replay the back-catalogue (realm-firsts,
|
||||
// deaths, single-holder achievements) the first boot the seam is live, so
|
||||
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||
@@ -257,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{})
|
||||
@@ -274,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()
|
||||
@@ -315,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"))
|
||||
@@ -337,6 +361,12 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "rest") {
|
||||
return p.handleDnDRestCmd(ctx, p.GetArgs(ctx.Body, "rest"))
|
||||
}
|
||||
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"))
|
||||
}
|
||||
@@ -434,6 +464,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "rivals") {
|
||||
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "news") {
|
||||
return p.handleNewsCmd(ctx)
|
||||
}
|
||||
|
||||
// 1. Arena commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "bail") {
|
||||
@@ -513,6 +546,10 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, advHelpText)
|
||||
case lower == "rivals":
|
||||
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":
|
||||
@@ -533,6 +570,14 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures")))
|
||||
case lower == "vault" || strings.HasPrefix(lower, "vault "):
|
||||
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
|
||||
case lower == "journal":
|
||||
return p.handleJournalCmd(ctx)
|
||||
case lower == "shadow":
|
||||
return p.handleShadowCmd(ctx)
|
||||
case lower == "worldboss" || strings.HasPrefix(lower, "worldboss "):
|
||||
return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("worldboss"):]))
|
||||
case lower == "siege" || strings.HasPrefix(lower, "siege "):
|
||||
return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("siege"):]))
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
||||
@@ -554,6 +599,7 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
||||
` + "`!adventure respond`" + ` — Respond to a mid-day event
|
||||
` + "`!adventure rivals`" + ` — View rival duel records
|
||||
` + "`!duel @user [stake]`" + ` — Challenge another adventurer to a staked, no-death bout (` + "`!duel accept`" + `/` + "`decline`" + `/` + "`status`" + `)
|
||||
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
|
||||
` + "`!adventure babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days)
|
||||
` + "`!adventure babysit focus <skill>`" + ` — Pick the skill auto-babysit trains (mining/fishing/foraging)
|
||||
@@ -564,6 +610,9 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
||||
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
||||
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
||||
` + "`!adventure journal`" + ` — Read the campaign pages you've recovered
|
||||
` + "`!adventure shadow`" + ` — See how your rival's run compares to yours
|
||||
` + "`!adventure worldboss`" + ` — The monthly Siege: one communal bout a day (` + "`fight`" + ` to join)
|
||||
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||
` + "`!adventure help`" + ` — This message
|
||||
@@ -628,6 +677,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
|
||||
@@ -778,6 +828,7 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
FishingSkill: c.FishingSkill,
|
||||
CurrentStreak: c.CurrentStreak,
|
||||
Renown: renownLevelForUser(c.UserID),
|
||||
})
|
||||
}
|
||||
text := renderAdvLeaderboard(entries)
|
||||
|
||||
@@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
}
|
||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
||||
|
||||
// N7/B3 the Omen — a payout-boosting week scales gross earnings before the
|
||||
// pot tax, so both the player's cut and the pot's rake grow proportionally.
|
||||
if m := activeOmen().ArenaPayoutMult; m > 1.0 {
|
||||
run.Earnings = int64(float64(run.Earnings) * m)
|
||||
}
|
||||
|
||||
// Arena tax: 10% of earnings to community pot.
|
||||
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
||||
arenaNet := run.Earnings - arenaTax
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -111,6 +114,25 @@ type AdventureCharacter struct {
|
||||
CraftsSucceeded int
|
||||
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
||||
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
||||
// N5/D1 the Hollow King campaign. Bitmask of discovered journal pages (bit i
|
||||
// == page i+1). Read-only overlay from player_meta.journal_pages; writes go
|
||||
// through the atomic grantJournalPageDB, never the bulk character save.
|
||||
JournalPages int64
|
||||
// N5/D1c the finale reward-once flag. True after the first finale clear;
|
||||
// overlay-read, written by the atomic markEpilogueClearedDB.
|
||||
EpilogueCleared bool
|
||||
// N7/B2 Renown — cumulative overflow XP earned past the L20 cap. Overlay-read
|
||||
// from player_meta.renown_xp, written by the atomic addRenownXP, never the
|
||||
// bulk character save. RenownLevel() derives the prestige level from it.
|
||||
RenownXP int
|
||||
}
|
||||
|
||||
// RenownLevel is the derived prestige level (renown_xp / renownXPPerLevel).
|
||||
func (c *AdventureCharacter) RenownLevel() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(c.RenownXP)
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -517,6 +539,41 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ensurePlayerMetaSeed guarantees the canonical player_meta seed row (and tier-0
|
||||
// equipment) exists for userID, creating it only when absent. The auto-migration
|
||||
// path (ensureDnDCharacterForCombat) writes a confirmed dnd_character without
|
||||
// touching the legacy layer; without this, a brand-new player whose first-ever
|
||||
// action auto-migrates — e.g. !rest or !cast before !setup — ends up with a
|
||||
// player_meta-less character that fails every legacy-layer command with
|
||||
// "sql: no rows" (the camcast straggler). Conditional on absence and thus
|
||||
// idempotent: legacy players who already have player_meta keep their equipment
|
||||
// untouched — createAdvCharacter's tier-0 equipment insert has no conflict guard
|
||||
// and would otherwise duplicate their gear.
|
||||
func ensurePlayerMetaSeed(userID id.UserID) error {
|
||||
d := db.Get()
|
||||
var one int
|
||||
err := d.QueryRow(`SELECT 1 FROM player_meta WHERE user_id = ?`, string(userID)).Scan(&one)
|
||||
if err == nil {
|
||||
return nil // already seeded
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
return createAdvCharacter(userID, localpartOf(userID))
|
||||
}
|
||||
|
||||
// localpartOf returns the mxid localpart (between @ and :) as a display-name
|
||||
// fallback — matches Base.DisplayName's offline behavior. The seed's display
|
||||
// name is overlaid by later player_meta upserts; this is just a sane default
|
||||
// for a character born without a Matrix client in reach.
|
||||
func localpartOf(userID id.UserID) string {
|
||||
s := string(userID)
|
||||
if i := strings.Index(s, ":"); i > 0 {
|
||||
return s[1:i]
|
||||
}
|
||||
return strings.TrimPrefix(s, "@")
|
||||
}
|
||||
|
||||
// saveAdvCharacter persists every mutable AdventureCharacter field to
|
||||
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
|
||||
// retired — the row is now read-only after createAdvCharacter seeds it,
|
||||
|
||||
702
internal/plugin/adventure_companion.go
Normal file
702
internal/plugin/adventure_companion.go
Normal file
@@ -0,0 +1,702 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Pete, the realm's embedded correspondent — the hireable NPC companion.
|
||||
// (pete_adventure_news_plan.md, "Pete as a character", surface 2.)
|
||||
//
|
||||
// A leader short a body can hire Pete into an expedition party. He fills the
|
||||
// role the party is missing, fights on autopilot, and files a dispatch about it
|
||||
// afterwards.
|
||||
//
|
||||
// The load-bearing rule, and the reason this file exists rather than a
|
||||
// player_meta row for @pete: **Pete is not a player and must never become one.**
|
||||
// He has no player_meta, no dnd_character, no inventory, no euros, and no DM
|
||||
// room. Mint him a player_meta row and ensureDnDCharacterForCombat will happily
|
||||
// auto-build him a real character on his first swing, at which point he shows up
|
||||
// in the graveyard, the leaderboards, the news as a subject, and the daily event
|
||||
// rolls — all of which would be a bot reporting on itself.
|
||||
//
|
||||
// So his seat is synthesized: companionCombatant builds a Combatant in memory
|
||||
// from the same tuned layers a player's sheet goes through, and every seam that
|
||||
// assumes a seat is a person is guarded by isCompanionSeat. The guards live at
|
||||
// four chokepoints, which between them cover the whole blast radius:
|
||||
//
|
||||
// - expeditionAudience — he is never in the DM fan-out (and so never in the
|
||||
// per-member pet-arrival rolls or the daily event rolls that ride it)
|
||||
// - partySize — he is not a mouth: he doesn't inflate the supply
|
||||
// burn he never bought packs for, and an NPC-only roster doesn't lock the
|
||||
// leader out of their next expedition
|
||||
// - partyCombatantsForSession / the seat builders — synthesize, don't load
|
||||
// - the close-out loops — no XP, no loot, no death row, no achievements
|
||||
//
|
||||
// He does count toward the enemy-HP scalar, because he is a body in the fight
|
||||
// and the boss can feel him.
|
||||
|
||||
// companionUserIDDefault is Pete's real Matrix account. He is an independent bot
|
||||
// (his own repo, his own voice); gogobee already ignores him as a sender via
|
||||
// IGNORED_BOTS, so seating him here can never round-trip into command handling.
|
||||
// PETE_USER_ID overrides for a differently-homed deployment.
|
||||
const companionUserIDDefault = "@pete:parodia.dev"
|
||||
|
||||
// companionDisplayName is what the party sees on his seat. gogobee names him but
|
||||
// does NOT voice him: his hire banter and his dispatch are written by his own
|
||||
// bot from the fact emitted below (project_pete_bot_architecture).
|
||||
const companionDisplayName = "Pete"
|
||||
|
||||
// Hire pricing. The plan files cost as an open tuning question; this is the
|
||||
// first answer, not the final one. It scales with both the level he shows up at
|
||||
// and the tier he's walking into, so hiring him for a T5 boss run is not the
|
||||
// same 300 coins as a T1 stroll. Supply packs are 50/90 coins for reference —
|
||||
// he is deliberately a real expense, not a rounding error.
|
||||
//
|
||||
// PROD WATCH: at endgame coin balances this is likely too cheap to be a sink.
|
||||
// Raise companionHireCoinsPerLevel before raising the base — the base is what a
|
||||
// low-level player short a friend has to find.
|
||||
const (
|
||||
companionHireBaseCoins = 300
|
||||
companionHireCoinsPerLevel = 60
|
||||
)
|
||||
|
||||
// companionLevelPenalty is what makes him help rather than carry. He arrives one
|
||||
// level below the party's average — a competent below-median member, per the
|
||||
// difficulty plan's standing rule: lift the trailing case, never nerf the
|
||||
// leaders and never touch monster scaling.
|
||||
const companionLevelPenalty = 1
|
||||
|
||||
var (
|
||||
ErrCompanionAlreadyHired = errors.New("pete is already with this party")
|
||||
ErrCompanionOnAssignment = errors.New("pete is out on assignment")
|
||||
ErrCompanionNotHired = errors.New("pete is not with this party")
|
||||
)
|
||||
|
||||
// companionUserID resolves Pete's Matrix id.
|
||||
func companionUserID() id.UserID {
|
||||
if v := strings.TrimSpace(os.Getenv("PETE_USER_ID")); v != "" {
|
||||
return id.UserID(v)
|
||||
}
|
||||
return id.UserID(companionUserIDDefault)
|
||||
}
|
||||
|
||||
// isCompanionSeat reports whether a roster seat / combat seat is Pete rather
|
||||
// than a player. This is the predicate every guard in the codebase keys on.
|
||||
func isCompanionSeat(userID id.UserID) bool { return userID == companionUserID() }
|
||||
|
||||
// isCompanionUser is the string-keyed form, for the many seams that carry a raw
|
||||
// user_id out of the database.
|
||||
func isCompanionUser(userID string) bool { return id.UserID(userID) == companionUserID() }
|
||||
|
||||
// companionHireCost is what the leader pays to bring him along, in coins.
|
||||
func companionHireCost(level int, tier ZoneTier) int {
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
t := int(tier)
|
||||
if t < 1 {
|
||||
t = 1
|
||||
}
|
||||
return (companionHireBaseCoins + companionHireCoinsPerLevel*level) * t
|
||||
}
|
||||
|
||||
// ── who he shows up as ───────────────────────────────────────────────────────
|
||||
|
||||
// companionRoleFill picks the class Pete plays. He is role-fluid — that is the
|
||||
// whole point of hiring him — so by default he fills the hole in the roster: no
|
||||
// healer, he's a Cleric; no damage, he's a Mage; nobody up front, he's a
|
||||
// Fighter. A leader who knows better can override.
|
||||
//
|
||||
// The order of the checks is the priority order: a party with neither a healer
|
||||
// nor a front line gets the healer, because a party that cannot heal is the one
|
||||
// that dies.
|
||||
func companionRoleFill(partyClasses []DnDClass) DnDClass {
|
||||
var hasHealer, hasFront, hasDamage bool
|
||||
for _, c := range partyClasses {
|
||||
switch c {
|
||||
case ClassCleric, ClassDruid, ClassBard:
|
||||
hasHealer = true
|
||||
case ClassPaladin:
|
||||
// The one chassis that answers two questions at once.
|
||||
hasHealer, hasFront = true, true
|
||||
case ClassFighter:
|
||||
hasFront = true
|
||||
case ClassMage, ClassSorcerer, ClassWarlock, ClassRogue, ClassRanger:
|
||||
hasDamage = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !hasHealer:
|
||||
return ClassCleric
|
||||
case !hasFront:
|
||||
return ClassFighter
|
||||
case !hasDamage:
|
||||
return ClassMage
|
||||
default:
|
||||
// A complete party that hires him anyway gets a second pair of hands up
|
||||
// front — the least redundant thing he can be.
|
||||
return ClassFighter
|
||||
}
|
||||
}
|
||||
|
||||
// parseCompanionClass resolves an explicit `!expedition hire cleric` override.
|
||||
// Empty (or unknown) means auto-fill.
|
||||
func parseCompanionClass(arg string) (DnDClass, bool) {
|
||||
arg = strings.ToLower(strings.TrimSpace(arg))
|
||||
if arg == "" || arg == "auto" {
|
||||
return "", false
|
||||
}
|
||||
for _, ci := range dndClasses {
|
||||
if !ci.Playable {
|
||||
continue
|
||||
}
|
||||
if string(ci.Key) == arg || strings.EqualFold(ci.Display, arg) {
|
||||
return ci.Key, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// companionHumans is every *player* on the expedition — the roster if one exists,
|
||||
// and otherwise the owner alone.
|
||||
//
|
||||
// The fallback is the whole point. A solo expedition has NO expedition_party rows
|
||||
// (see partyMembers: absence means solo, and the roster only materializes on the
|
||||
// first successful invite). Reading the roster alone therefore answers "nobody" for
|
||||
// exactly the player this feature exists for: the one with no friends around, who
|
||||
// is hiring Pete *because* they are alone.
|
||||
//
|
||||
// Getting this wrong is not a small error. It hired every solo player a **level-1**
|
||||
// Pete — in a tier-4 zone, against a boss that had just gained 15% HP and a full
|
||||
// extra set of actions to account for him. He died on contact and left the leader
|
||||
// fighting an inflated boss alone. A 1500-run sweep measured it: solo 65% clear,
|
||||
// two humans 87%, solo+Pete 33%. The companion was worse than no companion, and
|
||||
// this line is why.
|
||||
func companionHumans(expeditionID string) []*DnDCharacter {
|
||||
var owner string
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT user_id FROM dnd_expedition WHERE expedition_id = ?`,
|
||||
expeditionID).Scan(&owner); err != nil {
|
||||
return nil
|
||||
}
|
||||
seats, err := partyHumans(expeditionID, owner)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]*DnDCharacter, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
if dc, _ := LoadDnDCharacter(s.UserID); dc != nil {
|
||||
out = append(out, dc)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// companionPartyLevel is the level he arrives at: the party's average, less
|
||||
// companionLevelPenalty, floored at 1.
|
||||
func companionPartyLevel(expeditionID string) int {
|
||||
chars := companionHumans(expeditionID)
|
||||
if len(chars) == 0 {
|
||||
return 1
|
||||
}
|
||||
sum := 0
|
||||
for _, dc := range chars {
|
||||
sum += dc.Level
|
||||
}
|
||||
lvl := sum/len(chars) - companionLevelPenalty
|
||||
if lvl < 1 {
|
||||
lvl = 1
|
||||
}
|
||||
return lvl
|
||||
}
|
||||
|
||||
// companionPartyClasses reads the classes already on the expedition, for the role
|
||||
// fill. Solo resolves to the owner's class, so a lone fighter gets a healer rather
|
||||
// than the empty-party default.
|
||||
func companionPartyClasses(expeditionID string) []DnDClass {
|
||||
chars := companionHumans(expeditionID)
|
||||
out := make([]DnDClass, 0, len(chars))
|
||||
for _, dc := range chars {
|
||||
out = append(out, dc.Class)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── his sheet, which lives only in memory ────────────────────────────────────
|
||||
|
||||
// companionSheet synthesizes the DnDCharacter Pete fights as. It is built, used,
|
||||
// and thrown away inside a single combat build — it is never saved, and
|
||||
// SaveDnDCharacter must never be called on it.
|
||||
//
|
||||
// Stats come from the same class-priority + race-mod pipeline autoBuildCharacter
|
||||
// uses for a real character, so he is statted like a player of his level rather
|
||||
// than by a bespoke NPC table that would drift away from the tuned math. Human
|
||||
// is deliberate: the +1-to-all is the most neutral race in the book, so his
|
||||
// class is doing the work rather than a race pick nobody chose.
|
||||
func companionSheet(class DnDClass, level int) *DnDCharacter {
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
scores := applyRaceMods(RaceHuman, classStatPriority(class))
|
||||
c := &DnDCharacter{
|
||||
UserID: companionUserID(),
|
||||
Race: RaceHuman,
|
||||
Class: class,
|
||||
Level: level,
|
||||
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
return c
|
||||
}
|
||||
|
||||
// companionAdvCharacter is the AdventureCharacter half of the synthetic sheet:
|
||||
// the shim DerivePlayerStats needs. CombatLevel round-trips back to the D&D
|
||||
// level through dndLevelFromCombatLevel (level*5), so the two halves agree.
|
||||
func companionAdvCharacter(level int) *AdventureCharacter {
|
||||
return &AdventureCharacter{
|
||||
UserID: companionUserID(),
|
||||
DisplayName: companionDisplayName,
|
||||
CombatLevel: level * 5,
|
||||
Alive: true,
|
||||
}
|
||||
}
|
||||
|
||||
// companionGearTier maps his level onto the equipment tier a player of that
|
||||
// level would plausibly be carrying: 1–4 → T1, 5–8 → T2, and so on to T5.
|
||||
func companionGearTier(level int) int {
|
||||
t := (level + 3) / 4
|
||||
if t < 1 {
|
||||
t = 1
|
||||
}
|
||||
if t > 5 {
|
||||
t = 5
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// companionGear is the kit he walks in with — a working reporter's kit, bought
|
||||
// with expenses and kept in serviceable shape.
|
||||
//
|
||||
// He has to have one. The gear layer is not decoration: unarmored, stats.AC is
|
||||
// never set at all (computeArmorAC only fires when armor exists) and he walks in
|
||||
// at AC 3, hit by everything; weaponless, stats.Weapon stays nil and he swings
|
||||
// for a flat 5 at every level, which by L14 is nothing. "No gear" is not a
|
||||
// below-median player, it is a broken one.
|
||||
//
|
||||
// The below-median comes from everywhere else: he is a level down, his gear is
|
||||
// never Masterwork, and he carries no magic items, no subclass and no armed
|
||||
// ability. The weapon names are chosen to hit the right branch of
|
||||
// synthesizeWeaponProfile for the class — it best-fits off the name.
|
||||
func companionGear(class DnDClass, level int) map[EquipmentSlot]*AdvEquipment {
|
||||
tier := companionGearTier(level)
|
||||
|
||||
weapon := "Service Mace"
|
||||
switch class {
|
||||
case ClassFighter, ClassPaladin:
|
||||
weapon = "Service Sword"
|
||||
case ClassRogue:
|
||||
weapon = "Service Dagger"
|
||||
case ClassRanger:
|
||||
weapon = "Service Bow"
|
||||
case ClassMage, ClassSorcerer, ClassWarlock, ClassDruid, ClassBard:
|
||||
weapon = "Service Staff"
|
||||
}
|
||||
|
||||
return map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Slot: SlotWeapon, Tier: tier, Condition: 100, Name: weapon},
|
||||
SlotArmor: {Slot: SlotArmor, Tier: tier, Condition: 100, Name: "Service Kit"},
|
||||
}
|
||||
}
|
||||
|
||||
// companionCombatant builds Pete's seat for a fight, mirroring buildZoneCombatants
|
||||
// but sourcing the sheet from memory instead of the database. He carries no
|
||||
// equipment, no treasure bonuses, no magic items, no chat level and no streak —
|
||||
// the three layers a player accumulates and he never will. That absence *is* the
|
||||
// below-median: he is a bare class chassis at a level below yours, and the gap
|
||||
// between him and a geared player of the same level is exactly the gear.
|
||||
func (p *AdventurePlugin) companionCombatant(
|
||||
class DnDClass, level int, monster DnDMonsterTemplate, tier int, dmMood int,
|
||||
) (Combatant, Combatant, *DnDCharacter) {
|
||||
tilt := dmMoodCombatTilt(dmMood)
|
||||
char := companionAdvCharacter(level)
|
||||
dc := companionSheet(class, level)
|
||||
|
||||
// The layer order is buildZoneCombatants', deliberately — a companion statted
|
||||
// by a different pipeline would drift away from the tuned math the moment
|
||||
// anyone touched one and not the other.
|
||||
//
|
||||
// What he does NOT get is the subclass layer, magic items, and an armed
|
||||
// ability: three of the things a player accumulates and a hireling never will.
|
||||
// Those absences, plus the level penalty and gear that is never Masterwork,
|
||||
// are the "below median" — see companionGear for why the gear itself is not
|
||||
// one of the things we take away.
|
||||
gear := companionGear(class, level)
|
||||
stats, mods := DerivePlayerStats(char, gear, &AdvBonusSummary{}, 0, 0, false)
|
||||
applyDnDPlayerLayer(&stats, dc)
|
||||
applyDnDEquipmentLayer(&stats, dc, gear)
|
||||
applyDnDHPScaling(&stats, dc)
|
||||
applyClassPassives(&stats, &mods, dc)
|
||||
applyRacePassives(&stats, &mods, dc)
|
||||
|
||||
enemyStats, enemyMods := monster.toCombatStats()
|
||||
if tier > 1 {
|
||||
if floorAC := dndDungeonACBase + tier; enemyStats.AC < floorAC {
|
||||
enemyStats.AC = floorAC
|
||||
}
|
||||
if floorAB := dndDungeonAtkBase + tier; enemyStats.AttackBonus < floorAB {
|
||||
enemyStats.AttackBonus = floorAB
|
||||
}
|
||||
}
|
||||
enemyStats.Attack += tilt.EnemyAttackDelta
|
||||
if enemyStats.Attack < 1 {
|
||||
enemyStats.Attack = 1
|
||||
}
|
||||
mods.InitiativeBias += tilt.InitiativeBias
|
||||
|
||||
player := Combatant{
|
||||
Name: companionDisplayName,
|
||||
Stats: stats,
|
||||
Mods: mods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: monster.Name,
|
||||
Stats: enemyStats,
|
||||
Mods: enemyMods,
|
||||
Ability: monster.Ability,
|
||||
}
|
||||
return player, enemy, dc
|
||||
}
|
||||
|
||||
// companionRosterLine is how he reads on `!expedition party`. DisplayName would
|
||||
// come back empty for him — he has no player_meta row to hold a name — so the
|
||||
// roster names him here, along with what he is currently playing, because "Pete
|
||||
// (member)" tells the leader nothing about the hole they paid to fill.
|
||||
func companionRosterLine(expeditionID string) string {
|
||||
class, level := companionLoadout(expeditionID)
|
||||
ci, _ := classInfo(class)
|
||||
return fmt.Sprintf("**%s** _(hired — level %d %s)_\n", companionDisplayName, level, ci.Display)
|
||||
}
|
||||
|
||||
// ── the hire, persisted on the roster ────────────────────────────────────────
|
||||
|
||||
// companionLoadout reads back the class and level he was hired at. It is stored
|
||||
// on the roster row rather than re-derived per fight, so a party that levels
|
||||
// mid-expedition doesn't quietly re-roll their hireling into a different class
|
||||
// three rooms in.
|
||||
func companionLoadout(expeditionID string) (DnDClass, int) {
|
||||
var class string
|
||||
var level int
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT companion_class, companion_level
|
||||
FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&class, &level)
|
||||
if err != nil || class == "" {
|
||||
return ClassFighter, 1
|
||||
}
|
||||
return DnDClass(class), level
|
||||
}
|
||||
|
||||
// ── his spell slots, which live on the expedition ────────────────────────────
|
||||
//
|
||||
// A human caster's slots are dnd_spell_slots rows: one pool, spent across every
|
||||
// fight of the run, refilled only at camp. Rationing it is the caster's game. The
|
||||
// companion has no rows, so his pool lives on his roster row — the same row his
|
||||
// class and level live on, and with the same lifetime.
|
||||
//
|
||||
// It must NOT live on his combat seat. A seat is per-session and every fight opens
|
||||
// a new one, so a seat-scoped pool refills itself between fights: an infinite
|
||||
// caster. That is not a theory — the first cut did exactly that, and the sim
|
||||
// measured a gearless, level-penalized hireling out-clearing a human cleric of the
|
||||
// leader's own level by 15pp.
|
||||
|
||||
// companionSlotsCSV encodes/decodes the ledger. CSV of six ints rather than JSON
|
||||
// because it is six ints.
|
||||
func companionSlotsDecode(s string) [6]int {
|
||||
var out [6]int
|
||||
for i, f := range strings.Split(s, ",") {
|
||||
if i >= len(out) {
|
||||
break
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(f))
|
||||
if err != nil || n < 0 {
|
||||
continue
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func companionSlotsEncode(used [6]int) string {
|
||||
parts := make([]string, len(used))
|
||||
for i, n := range used {
|
||||
parts[i] = strconv.Itoa(n)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// companionSlotsForRun reads the ledger for the companion on the expedition that
|
||||
// owns runID. A run with no companion (or no expedition) reads as an empty pool,
|
||||
// which is the correct answer: nobody spent anything.
|
||||
func companionSlotsForRun(runID string) [6]int {
|
||||
var raw string
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT p.companion_slots_used
|
||||
FROM expedition_party p
|
||||
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||
WHERE e.run_id = ? AND p.user_id = ?`,
|
||||
runID, string(companionUserID())).Scan(&raw)
|
||||
if err != nil {
|
||||
return [6]int{}
|
||||
}
|
||||
return companionSlotsDecode(raw)
|
||||
}
|
||||
|
||||
// setCompanionSlotsForRun writes it back.
|
||||
func setCompanionSlotsForRun(runID string, used [6]int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party
|
||||
SET companion_slots_used = ?
|
||||
WHERE user_id = ?
|
||||
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
|
||||
companionSlotsEncode(used), string(companionUserID()), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshCompanionSlots empties the ledger — his half of the camp rest that calls
|
||||
// refreshSpellSlots for every human. Keyed by expedition, because camp is.
|
||||
func refreshCompanionSlots(expeditionID string) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_slots_used = ''
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// ── his body, which is also carried across the run ───────────────────────────
|
||||
//
|
||||
// companionUnsetHP is "no wound recorded" — a fresh hire, or a companion who has
|
||||
// just broken camp. Seating reads it as full.
|
||||
const companionUnsetHP = -1
|
||||
|
||||
// companionHPFor reads the HP he carries into his next fight, or companionUnsetHP
|
||||
// when he is unhurt. A run with no companion reads unset, which is harmless: there
|
||||
// is nobody to seat.
|
||||
func companionHPFor(expeditionID string) int {
|
||||
hp := companionUnsetHP
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT companion_hp FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&hp)
|
||||
if err != nil {
|
||||
return companionUnsetHP
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
// companionSeatHP is what he actually sits down with: his carried wound, clamped
|
||||
// into [1, maxHP].
|
||||
//
|
||||
// The floor of 1 is deliberate. He can be dropped *inside* a fight — the engine
|
||||
// counts him out like any other seat — but he does not stay dead between them,
|
||||
// because there is no companion-death mechanic and inventing one here would be a
|
||||
// second feature smuggled into a bug fix. Coming back on 1 HP is a real penalty
|
||||
// (one hit and he is down again) without pretending to be a corpse rule.
|
||||
func companionSeatHP(expeditionID string, maxHP int) int {
|
||||
hp := companionHPFor(expeditionID)
|
||||
if hp == companionUnsetHP || hp > maxHP {
|
||||
return maxHP
|
||||
}
|
||||
if hp < 1 {
|
||||
return 1
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
// setCompanionHP records the HP he walked out of a fight with.
|
||||
func setCompanionHP(expeditionID string, hp int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_hp = ?
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
hp, expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// setCompanionHPForRun is setCompanionHP for the turn-based close-out, which
|
||||
// holds a run id rather than an expedition id.
|
||||
func setCompanionHPForRun(runID string, hp int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party
|
||||
SET companion_hp = ?
|
||||
WHERE user_id = ?
|
||||
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
|
||||
hp, string(companionUserID()), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshCompanionHP is his half of the camp heal: back to full, like every human
|
||||
// at a standard rest.
|
||||
func refreshCompanionHP(expeditionID string) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_hp = ?
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
companionUnsetHP, expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// companionLoadoutForRun is companionLoadout keyed by the zone run instead of
|
||||
// the expedition. A CombatSession carries a RunID, not an expedition id, and the
|
||||
// per-turn rebuild is the hottest caller — so the join lives here rather than
|
||||
// making every caller resolve the expedition first.
|
||||
//
|
||||
// A run with no expedition (a standalone !zone run, which can have no companion)
|
||||
// finds no row and falls back, which is the correct reading.
|
||||
func companionLoadoutForRun(runID string) (DnDClass, int) {
|
||||
var class string
|
||||
var level int
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT p.companion_class, p.companion_level
|
||||
FROM expedition_party p
|
||||
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||
WHERE e.run_id = ? AND p.user_id = ?`,
|
||||
runID, string(companionUserID())).Scan(&class, &level)
|
||||
if err != nil || class == "" {
|
||||
return ClassFighter, 1
|
||||
}
|
||||
return DnDClass(class), level
|
||||
}
|
||||
|
||||
// companionExpeditionFor resolves the expedition a leader is running, for the
|
||||
// seat builder — which has the roster but not the expedition id. Empty string
|
||||
// when there is none, which companionLoadout reads as "no row" and falls back.
|
||||
func companionExpeditionFor(leader id.UserID) string {
|
||||
e, _, err := activeExpeditionFor(leader)
|
||||
if err != nil || e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
// companionSeated reports whether Pete is on this roster.
|
||||
func companionSeated(expeditionID string) bool {
|
||||
var one int
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT 1 FROM expedition_party WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&one)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// hireCompanion seats Pete. The seat check, the availability check and the
|
||||
// insert share a transaction, so two leaders racing for him cannot both win.
|
||||
//
|
||||
// He is globally exclusive — one party at a time. That is not a limitation to
|
||||
// route around: "Pete is out on assignment" is the scarcity knob the plan asks
|
||||
// for, and it is also why he cannot be the answer to every run.
|
||||
func hireCompanion(expeditionID string, class DnDClass, level int) error {
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := seatLeader(tx, expeditionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Is he already out with somebody? Scoped to live expeditions, so a roster
|
||||
// row stranded by a crash cannot make him unhireable forever.
|
||||
var busyOn string
|
||||
err = tx.QueryRow(`
|
||||
SELECT p.expedition_id
|
||||
FROM expedition_party p
|
||||
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||
WHERE p.user_id = ? AND e.status IN ('active', 'extracting')
|
||||
LIMIT 1`, string(companionUserID())).Scan(&busyOn)
|
||||
switch {
|
||||
case err == nil && busyOn == expeditionID:
|
||||
return ErrCompanionAlreadyHired
|
||||
case err == nil:
|
||||
return ErrCompanionOnAssignment
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return err
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
|
||||
expeditionID).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n >= expeditionPartyMax {
|
||||
return ErrPartyFull
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO expedition_party (expedition_id, user_id, role, companion_class, companion_level)
|
||||
VALUES (?, ?, 'member', ?, ?)`,
|
||||
expeditionID, string(companionUserID()), string(class), level); err != nil {
|
||||
return fmt.Errorf("seat companion: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// dismissCompanion sends him home. Unlike a player member he can be removed
|
||||
// mid-run — he is staff, not a guest — but the fee is not refunded: he already
|
||||
// walked in.
|
||||
func dismissCompanion(expeditionID string) error {
|
||||
res, err := db.Get().Exec(`
|
||||
DELETE FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrCompanionNotHired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── the dispatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
// emitCompanionHireFact tells Pete's bot he has been hired. gogobee states the
|
||||
// fact; Pete writes the sentence. Bulletin, not priority: a hire is a diary
|
||||
// entry, not a bulletin-interrupting event.
|
||||
//
|
||||
// The subject is the *leader*, so the leader's news opt-out is honoured — Pete
|
||||
// reporting "filled in as cleric for <someone who asked not to be named>" would
|
||||
// be exactly the leak the opt-out exists to prevent. Pete himself is never a
|
||||
// subject: he has no opt-out row and needs none.
|
||||
func emitCompanionHireFact(leader id.UserID, class DnDClass, level int, zone ZoneDefinition) {
|
||||
name := charName(leader)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
ci, _ := classInfo(class)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: "companion_hire:" + eventToken(leader, "companion_hire") + ":" + fmt.Sprint(time.Now().UTC().Unix()),
|
||||
EventType: "companion_hire",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Level: level,
|
||||
ClassRace: ci.Display,
|
||||
OccurredAt: time.Now().UTC().Unix(),
|
||||
}, leader, "")
|
||||
}
|
||||
309
internal/plugin/adventure_companion_test.go
Normal file
309
internal/plugin/adventure_companion_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The companion's whole contract is "he fights, and he is not a player". These
|
||||
// tests pin both halves — and specifically the seams where an NPC seat would
|
||||
// otherwise be silently treated as a person.
|
||||
|
||||
func TestCompanion_HiredSeatIsNotAMouth(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@leader:example.org")
|
||||
seedExpedition(t, "exp-hire", owner, "active")
|
||||
seatLeaderFixture(t, "exp-hire")
|
||||
|
||||
if err := hireCompanion("exp-hire", ClassCleric, 4); err != nil {
|
||||
t.Fatalf("hireCompanion: %v", err)
|
||||
}
|
||||
|
||||
// The roster holds two seats...
|
||||
members, err := partyMembers("exp-hire")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("roster has %d seats, want 2 (leader + companion)", len(members))
|
||||
}
|
||||
|
||||
// ...but only one of them eats. partySize feeds the daily supply burn and the
|
||||
// "your party is still waiting on you" lock-out; counting the companion would
|
||||
// bill the leader for rations he never bought, and strand him out of his next
|
||||
// expedition behind a party of one bot.
|
||||
n, err := partySize("exp-hire")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("partySize = %d, want 1 — the companion is not a mouth", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanion_GetsNoMailButTakesASeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@leader2:example.org")
|
||||
seedExpedition(t, "exp-mail", owner, "active")
|
||||
seatLeaderFixture(t, "exp-mail")
|
||||
if err := hireCompanion("exp-mail", ClassFighter, 3); err != nil {
|
||||
t.Fatalf("hireCompanion: %v", err)
|
||||
}
|
||||
exp, err := getExpedition("exp-mail")
|
||||
if err != nil || exp == nil {
|
||||
t.Fatalf("getExpedition: %v", err)
|
||||
}
|
||||
|
||||
// Mail and seats are different sets. Every DM seam reads the audience; the
|
||||
// combat roster reads the seats. Getting this backwards either DMs a bot or
|
||||
// charges a leader for a body that never sits down.
|
||||
for _, uid := range expeditionAudience(exp) {
|
||||
if isCompanionSeat(uid) {
|
||||
t.Fatal("companion is in the DM audience — he does not get mail")
|
||||
}
|
||||
}
|
||||
var seated bool
|
||||
for _, uid := range expeditionSeats(exp) {
|
||||
if isCompanionSeat(uid) {
|
||||
seated = true
|
||||
}
|
||||
}
|
||||
if !seated {
|
||||
t.Fatal("companion is not in the fight roster — he was paid for and never sat down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanion_IsGloballyExclusive(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
for _, e := range []struct {
|
||||
id string
|
||||
owner id.UserID
|
||||
}{{"exp-a", "@a:example.org"}, {"exp-b", "@b:example.org"}} {
|
||||
seedExpedition(t, e.id, e.owner, "active")
|
||||
seatLeaderFixture(t, e.id)
|
||||
}
|
||||
|
||||
if err := hireCompanion("exp-a", ClassMage, 5); err != nil {
|
||||
t.Fatalf("first hire: %v", err)
|
||||
}
|
||||
// He is one person. A second party cannot have him — "out on assignment" is
|
||||
// the scarcity knob, not a bug to route around.
|
||||
if err := hireCompanion("exp-b", ClassMage, 5); !errors.Is(err, ErrCompanionOnAssignment) {
|
||||
t.Errorf("second hire err = %v, want ErrCompanionOnAssignment", err)
|
||||
}
|
||||
// Re-hiring him into the party he's already with is its own answer.
|
||||
if err := hireCompanion("exp-a", ClassMage, 5); !errors.Is(err, ErrCompanionAlreadyHired) {
|
||||
t.Errorf("re-hire err = %v, want ErrCompanionAlreadyHired", err)
|
||||
}
|
||||
|
||||
// Dismissed, he's available again.
|
||||
if err := dismissCompanion("exp-a"); err != nil {
|
||||
t.Fatalf("dismiss: %v", err)
|
||||
}
|
||||
if err := hireCompanion("exp-b", ClassMage, 5); err != nil {
|
||||
t.Errorf("hire after dismiss: %v, want success", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The bug that made the whole feature worse than useless: a SOLO expedition has
|
||||
// no expedition_party rows at all (the roster only materializes on the first
|
||||
// invite), so reading the roster to size the companion answered "nobody" for
|
||||
// exactly the player who is hiring him — the one with no friends around. Every
|
||||
// solo hire got a level-1 Pete, in whatever tier the leader was actually in.
|
||||
func TestCompanion_SoloLeaderSizesHimCorrectly(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@lonely:example.org")
|
||||
seedExpedition(t, "exp-solo-hire", owner, "active")
|
||||
|
||||
// A level-12 fighter, adventuring alone. No roster rows exist.
|
||||
if err := SaveDnDCharacter(&DnDCharacter{
|
||||
UserID: owner, Race: RaceHuman, Class: ClassFighter, Level: 12,
|
||||
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := companionPartyLevel("exp-solo-hire"); got != 11 {
|
||||
t.Errorf("companionPartyLevel = %d, want 11 (the lone leader's 12, less the below-median step). "+
|
||||
"A level-1 companion in the leader's zone is worse than no companion at all.", got)
|
||||
}
|
||||
// And he fills the hole the lone fighter actually has, rather than defaulting
|
||||
// as if the party were empty.
|
||||
if got := companionRoleFill(companionPartyClasses("exp-solo-hire")); got != ClassCleric {
|
||||
t.Errorf("role fill for a lone fighter = %v, want cleric", got)
|
||||
}
|
||||
}
|
||||
|
||||
// §4 — asking for "the party" must never answer "nobody".
|
||||
//
|
||||
// A solo expedition has no expedition_party rows (absence means solo; the roster
|
||||
// materializes on the first invite). Every consumer that read the roster table
|
||||
// directly therefore got an empty list for a solo player and fell back to
|
||||
// whatever looked reasonable locally. That is how the companion was hired at
|
||||
// level 1 for exactly the player the feature exists for.
|
||||
//
|
||||
// expeditionParty always includes the owner. If this test ever fails, that
|
||||
// guarantee is gone and the same class of bug is back.
|
||||
func TestParty_SoloExpeditionStillHasAParty(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@alone:example.org")
|
||||
seedExpedition(t, "exp-alone", owner, "active")
|
||||
|
||||
// No roster rows exist at all.
|
||||
rows, err := partyMembers("exp-alone")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("solo expedition has %d roster rows; the premise of this test is gone", len(rows))
|
||||
}
|
||||
|
||||
// And yet the party is not empty.
|
||||
seats, err := expeditionParty("exp-alone", string(owner))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seats) != 1 || seats[0].UserID != owner || seats[0].Kind != SeatLeader {
|
||||
t.Fatalf("expeditionParty on a solo run = %+v, want exactly the owner as leader. "+
|
||||
"An empty answer here is what hires a level-1 companion.", seats)
|
||||
}
|
||||
humans, err := partyHumans("exp-alone", string(owner))
|
||||
if err != nil || len(humans) != 1 {
|
||||
t.Fatalf("partyHumans on a solo run = %+v (err %v), want the owner", humans, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanion_RoleFillTakesTheHole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
party []DnDClass
|
||||
want DnDClass
|
||||
}{
|
||||
{"no healer", []DnDClass{ClassFighter, ClassRogue}, ClassCleric},
|
||||
{"no front line", []DnDClass{ClassCleric, ClassMage}, ClassFighter},
|
||||
{"no damage", []DnDClass{ClassCleric, ClassFighter}, ClassMage},
|
||||
// A paladin covers healer AND front line, so paladin+rogue has no hole at
|
||||
// all — it falls through to the default, a second body up front.
|
||||
{"a complete party gets the default", []DnDClass{ClassPaladin, ClassRogue}, ClassFighter},
|
||||
{"a paladin still leaves the damage hole", []DnDClass{ClassPaladin}, ClassMage},
|
||||
{"solo fighter needs the medic first", []DnDClass{ClassFighter}, ClassCleric},
|
||||
{"empty party", nil, ClassCleric},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := companionRoleFill(tc.party); got != tc.want {
|
||||
t.Errorf("companionRoleFill(%v) = %v, want %v", tc.party, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// §3 — an engine-driven seat's latch is permanent, and no command can take it.
|
||||
//
|
||||
// This is the invariant that, when it did not exist, made the companion stand in
|
||||
// fights doing nothing. The expedition autopilot drives a party by dispatching
|
||||
// each seat's turn AS that seat; his own auto-played move therefore arrived at
|
||||
// beginCombatTurn looking exactly like a human returning to the keyboard, and the
|
||||
// "they typed, so they're here" branch cleared the latch that was moving him.
|
||||
// After round 1 he never acted again — while the boss he had inflated by 15% HP
|
||||
// killed the party. The sweep measured it at -27pp clear rate.
|
||||
//
|
||||
// Autopilot is provisional (a human is away; a keystroke ends it). EngineDriven is
|
||||
// not (there is nobody to come back). They must never collapse into one flag.
|
||||
func TestCompanion_EngineSeatKeepsTheWheel(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
uid := id.UserID("@lead:example.org")
|
||||
|
||||
monster := dndBestiary["goblin"]
|
||||
c, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
|
||||
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
|
||||
|
||||
sess, err := p.startPartyCombatSession("run-e", "enc", "goblin", &enemy, []CombatSeatSetup{
|
||||
{UserID: uid, HP: 200, HPMax: 200, Mods: c.Mods, C: &c},
|
||||
{UserID: companionUserID(), HP: 120, HPMax: 120, Mods: c.Mods, C: &c, EngineDriven: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !sess.seatIsEngineDriven(1) {
|
||||
t.Fatal("companion seat is not engine-driven")
|
||||
}
|
||||
if sess.seatIsEngineDriven(0) {
|
||||
t.Fatal("the human's seat came out engine-driven")
|
||||
}
|
||||
// The engine drives it without waiting on anybody...
|
||||
if !sess.seatIsAutopiloted(1) || !sess.seatNeedsNoHuman(1) {
|
||||
t.Fatal("an engine seat must resolve without waiting for a human")
|
||||
}
|
||||
// ...and the human's seat still waits for its human.
|
||||
if sess.seatIsAutopiloted(0) {
|
||||
t.Fatal("the human's seat was latched onto autopilot at seating")
|
||||
}
|
||||
|
||||
// The wheel cannot be taken back, because there is nobody to take it. This is
|
||||
// the exact line that used to strand him: a "keystroke" from his own id.
|
||||
sess.actorStatusesPtr(1).Autopilot = false
|
||||
if !sess.seatIsAutopiloted(1) {
|
||||
t.Fatal("clearing Autopilot stranded the engine seat — EngineDriven must keep it moving")
|
||||
}
|
||||
}
|
||||
|
||||
// The one that actually matters: he has to FIGHT. Every other guard in this
|
||||
// file is about keeping him out of things, and a companion synthesized down to
|
||||
// zeroed stats would pass all of them while standing in the fight doing nothing
|
||||
// — a hire that silently buys the leader an empty chair.
|
||||
func TestCompanion_ActuallySwings(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
monster := dndBestiary["goblin"]
|
||||
tank := monster
|
||||
tank.HP = 5000 // outlast the phase clock, so a real swing has time to land
|
||||
|
||||
pete, _, _ := p.companionCombatant(ClassFighter, 6, tank, 2, 0)
|
||||
|
||||
if pete.Stats.MaxHP <= 0 || pete.Stats.Attack <= 0 || pete.Stats.AC <= 0 {
|
||||
t.Fatalf("companion built with dead stats (%d HP / %d atk / %d AC) — he'd stand in the fight and do nothing",
|
||||
pete.Stats.MaxHP, pete.Stats.Attack, pete.Stats.AC)
|
||||
}
|
||||
|
||||
enemy := Combatant{Name: tank.Name, Stats: CombatStats{MaxHP: 5000, AC: 10, Attack: 1, AttackBonus: 1}}
|
||||
res := simulatePartyWithRNG([]Combatant{pete}, enemy, dungeonCombatPhases, seededRNG(7))
|
||||
|
||||
var swings int
|
||||
for _, e := range res.Events {
|
||||
if e.Actor == "player" && e.Seat == 0 && e.Roll > 0 {
|
||||
swings++
|
||||
}
|
||||
}
|
||||
if swings == 0 {
|
||||
t.Fatal("the companion never swung — he was hired, seated, and did nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanion_SheetIsBelowMedianAndNeverPersisted(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
|
||||
// He is statted like a player of his level, so he never drifts from the tuned
|
||||
// math — but he arrives a level down, which is the whole "help, never a carry"
|
||||
// rule expressed in one number.
|
||||
dc := companionSheet(ClassFighter, 7)
|
||||
if dc.Level != 7 || dc.HPMax <= 0 {
|
||||
t.Fatalf("companionSheet = level %d / %d HP, want a real level-7 chassis", dc.Level, dc.HPMax)
|
||||
}
|
||||
|
||||
// And he leaves no trace: a player_meta row is the thing that would turn him
|
||||
// into a real character everywhere (graveyard, news, XP, leaderboards), so the
|
||||
// synthesis must not have written one.
|
||||
if c, _ := LoadDnDCharacter(companionUserID()); c != nil {
|
||||
t.Fatal("companion has a persisted dnd_character row — he is a player now, which is the one thing he must never be")
|
||||
}
|
||||
if _, err := loadAdvCharacter(companionUserID()); err == nil {
|
||||
t.Fatal("companion has a persisted player_meta row — ensureDnDCharacterForCombat will auto-build him a character on his first swing")
|
||||
}
|
||||
}
|
||||
718
internal/plugin/adventure_duel.go
Normal file
718
internal/plugin/adventure_duel.go
Normal file
@@ -0,0 +1,718 @@
|
||||
package plugin
|
||||
|
||||
// C2 — player-initiated duels (engagement plan N6).
|
||||
//
|
||||
// `!duel @user [stake]` — a staked, no-death arena bout between two real
|
||||
// characters, resolved through the auto-resolve combat engine. Both players
|
||||
// escrow the stake on accept; the winner takes 70% of the pooled 2×stake and
|
||||
// the community pot rakes the remaining 30% (a real pot sink). Records land in
|
||||
// adventure_rival_records, so one W/L history and one 7-day per-pair cooldown
|
||||
// covers both duel kinds (the bot-driven RPS rival and this).
|
||||
//
|
||||
// Why this is a wrapper and not a combat-engine change: the turn engine is an
|
||||
// N-players-vs-1-monster model — there is no seat for a second real character
|
||||
// on the enemy side (see the C2 feasibility note in the engagement plan). So a
|
||||
// duel does not seat two PCs against each other. Instead each fighter is built
|
||||
// as their real player Combatant (full kit: weapon, subclass passives, extra
|
||||
// attacks, race traits) and the *opponent* is synthesised as a monster-shaped
|
||||
// enemy stat block from their own sheet. That is asymmetric — the "player" side
|
||||
// runs its full kit while the synthesised enemy is a flat stat block — so a
|
||||
// single bout would systematically favour whoever is the attacker. The duel
|
||||
// runs the bout in BOTH orientations and decides on the aggregate HP margin,
|
||||
// which cancels that edge symmetrically. To-hit is faithful either way (both
|
||||
// sides roll d20 + AttackBonus vs AC); only damage-per-hit is folded into the
|
||||
// synthesised enemy's flat Attack. Class-vs-class PvP balance is explicitly out
|
||||
// of scope (bragging rights, capped stakes).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
duelMinLevel = 3 // both duellists must be at least this level
|
||||
duelAcceptWindow = 24 * time.Hour // an unanswered challenge expires and refunds
|
||||
duelStakePerLvl = 500 // stake cap = challenger level × this
|
||||
duelMinStake = 100 // floor so a "duel" always has something on it
|
||||
duelWinnerPct = 0.70 // winner's cut of the pooled 2×stake; pot rakes the rest
|
||||
)
|
||||
|
||||
// advDuelChallenge is one outstanding duel invitation. Unlike the RPS rival
|
||||
// challenge it carries no per-round score — a combat duel resolves in a single
|
||||
// step on accept. The challenger's stake is already escrowed (debited) when the
|
||||
// row exists; the challenged player's is debited on accept.
|
||||
type advDuelChallenge struct {
|
||||
ChallengeID string
|
||||
ChallengerID id.UserID
|
||||
ChallengedID id.UserID
|
||||
Stake int
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
func insertDuelChallenge(c *advDuelChallenge) {
|
||||
db.Exec("duel: insert challenge",
|
||||
`INSERT INTO adventure_duel_challenges
|
||||
(challenge_id, challenger_id, challenged_id, stake, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID), c.Stake, c.ExpiresAt,
|
||||
)
|
||||
}
|
||||
|
||||
func deleteDuelChallenge(challengeID string) {
|
||||
db.Exec("duel: delete challenge",
|
||||
`DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID)
|
||||
}
|
||||
|
||||
// claimDuelChallenge atomically deletes the row and reports whether THIS caller
|
||||
// is the one that removed it. It is the single serialization point for a
|
||||
// challenge's terminal fate: accept, decline and the expiry ticker all race to
|
||||
// claim it, and exactly one wins. Only the winner moves euros — that is what
|
||||
// stops a stake being both resolved and refunded across the 24h boundary, or a
|
||||
// double `!duel decline` double-refunding. Mirrors communityPotDebit's
|
||||
// RowsAffected gate.
|
||||
func claimDuelChallenge(challengeID string) bool {
|
||||
res := db.ExecResult("duel: claim challenge",
|
||||
`DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID)
|
||||
if res == nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1
|
||||
}
|
||||
|
||||
func scanDuelChallenge(row interface{ Scan(...any) error }) (*advDuelChallenge, error) {
|
||||
c := &advDuelChallenge{}
|
||||
err := row.Scan(&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// pendingDuelForChallenged returns the live (unexpired) challenge awaiting this
|
||||
// player's answer, or nil. Expired rows are ignored here and swept by the
|
||||
// ticker, so a player is never shown a challenge they can no longer accept.
|
||||
func pendingDuelForChallenged(userID id.UserID) *advDuelChallenge {
|
||||
c := &advDuelChallenge{}
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
|
||||
FROM adventure_duel_challenges
|
||||
WHERE challenged_id = ? AND expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY created_at DESC LIMIT 1`, string(userID)).Scan(
|
||||
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// duelInvolves reports whether the player has any live challenge in flight, as
|
||||
// challenger or challenged. Used to enforce one duel at a time per player — the
|
||||
// escrow makes overlapping challenges a way to double-commit the same euros.
|
||||
func duelInvolves(userID id.UserID) bool {
|
||||
var n int
|
||||
db.Get().QueryRow(`
|
||||
SELECT COUNT(*) FROM adventure_duel_challenges
|
||||
WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
|
||||
string(userID), string(userID)).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func loadExpiredDuelChallenges() []advDuelChallenge {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
|
||||
FROM adventure_duel_challenges WHERE expires_at <= CURRENT_TIMESTAMP`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []advDuelChallenge
|
||||
for rows.Next() {
|
||||
if c, err := scanDuelChallenge(rows); err == nil {
|
||||
out = append(out, *c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Stake / gating ───────────────────────────────────────────────────────────
|
||||
|
||||
// duelMaxStake caps the stake at level × duelStakePerLvl so a strong player
|
||||
// can't farm a runaway pot off a weaker one, per the plan's imbalance cap.
|
||||
func duelMaxStake(level int) int { return level * duelStakePerLvl }
|
||||
|
||||
// duelPayout splits the pooled 2×stake: the winner takes duelWinnerPct, the
|
||||
// community pot rakes the remainder. Both fighters escrowed stake, so the
|
||||
// winner's net gain is winnerShare − stake and the pot is a real sink.
|
||||
func duelPayout(stake int) (winnerShare, potShare int) {
|
||||
pool := stake * 2
|
||||
winnerShare = int(math.Round(float64(pool) * duelWinnerPct))
|
||||
potShare = pool - winnerShare
|
||||
return
|
||||
}
|
||||
|
||||
// duelEligible reports whether a character can duel at all: alive, set up, and
|
||||
// at least duelMinLevel. Returns a player-facing reason when it can't.
|
||||
func (p *AdventurePlugin) duelEligible(char *AdventureCharacter) (int, string) {
|
||||
if char == nil {
|
||||
return 0, "no adventurer"
|
||||
}
|
||||
if !char.Alive {
|
||||
return 0, "dead"
|
||||
}
|
||||
lvl := rivalLevelForUser(char)
|
||||
if lvl < duelMinLevel {
|
||||
return lvl, fmt.Sprintf("below level %d", duelMinLevel)
|
||||
}
|
||||
return lvl, ""
|
||||
}
|
||||
|
||||
// ── Combat synthesis ─────────────────────────────────────────────────────────
|
||||
|
||||
// buildDuelist derives a fighter's full player Combatant — the same build a
|
||||
// zone encounter uses, so weapon, subclass passives, extra attacks and race
|
||||
// traits all apply — then resets it to full health. A duel is a fresh arena
|
||||
// bout, not a continuation of a wounded expedition, so wound carry-over is
|
||||
// dropped (StartHP=0 = "enter at MaxHP"). Mood 50 is the neutral band: no
|
||||
// initiative bias, no enemy-attack tilt.
|
||||
//
|
||||
// The armed-ability slot is left empty: actively-armed one-shots (a Berserker's
|
||||
// rage) don't fire, but always-on passives (Extra Attack, Sneak Attack, Divine
|
||||
// Strike, race traits) still layer in via the class/subclass passive builders.
|
||||
func (p *AdventurePlugin) buildDuelist(userID id.UserID) (Combatant, error) {
|
||||
var noMonster DnDMonsterTemplate
|
||||
player, _, _, err := p.buildZoneCombatants(userID, noMonster, 1, 50, "")
|
||||
if err != nil {
|
||||
return Combatant{}, err
|
||||
}
|
||||
player.Stats.StartHP = 0
|
||||
return player, nil
|
||||
}
|
||||
|
||||
// duelPerHitDamage is a fighter's expected damage on a single landed hit, used
|
||||
// to size the flat Attack of their synthesised enemy stat block. It mirrors the
|
||||
// player damage path's terms that the enemy path can't express: weapon dice
|
||||
// average (or the legacy flat Attack for a weaponless build), the always-on
|
||||
// per-hit riders (Divine Strike, Sneak Attack, Hunter's Mark, rage), then the
|
||||
// multiplicative damage bonus. Crit chance and once-per-fight openers are not
|
||||
// modelled — a coarse mean is enough for a capped-stakes bout decided over two
|
||||
// orientations.
|
||||
func duelPerHitDamage(pc Combatant) float64 {
|
||||
base := float64(pc.Stats.Attack)
|
||||
if w := pc.Stats.Weapon; w != nil {
|
||||
if pc.Stats.TwoHandedMode && w.HasProperty(PropVersatile) && w.VersaCount > 0 {
|
||||
base = float64(w.VersaCount)*(float64(w.VersaSides)+1)/2 +
|
||||
float64(pc.Stats.AbilityModForDamage) + float64(w.MagicBonus)
|
||||
} else {
|
||||
base = avgWeaponDamage(w, pc.Stats.AbilityModForDamage)
|
||||
}
|
||||
}
|
||||
riders := float64(pc.Mods.DivineStrikePerHit)
|
||||
riders += float64(pc.Mods.SneakAttackDie) * 3.5
|
||||
riders += float64(pc.Mods.HuntersMarkDie) * 3.5
|
||||
if pc.Mods.BerserkerRage {
|
||||
riders += float64(pc.Mods.RageMeleeDmg)
|
||||
}
|
||||
perHit := (base + riders) * (1 + pc.Mods.DamageBonus)
|
||||
if perHit < 1 {
|
||||
perHit = 1
|
||||
}
|
||||
return perHit
|
||||
}
|
||||
|
||||
// duelPerRoundProcDamage is the expected per-round damage from a fighter's
|
||||
// companion strikes — a Cleric's spiritual weapon and a Beastmaster's pet —
|
||||
// which land once per round on a proc roll rather than per weapon hit. The live
|
||||
// fighter rolls these natively in the engine; folding their expectation into the
|
||||
// synth enemy's round damage keeps both orientations comparable for those
|
||||
// builds (each formula is Dmg + d5, mean +3).
|
||||
func duelPerRoundProcDamage(pc Combatant) float64 {
|
||||
dmg := pc.Mods.SpiritWeaponProc * (float64(pc.Mods.SpiritWeaponDmg) + 3)
|
||||
dmg += pc.Mods.PetAttackProc * (float64(pc.Mods.PetAttackDmg) + 3)
|
||||
return dmg
|
||||
}
|
||||
|
||||
// synthDuelEnemy turns a fighter's player Combatant into the monster-shaped
|
||||
// enemy the opposite fighter faces. HP, AC and AttackBonus carry over verbatim
|
||||
// (to-hit stays faithful — the enemy rolls d20 + AttackBonus vs the attacker's
|
||||
// AC exactly as the player does). DamageReduct carries over too, so a
|
||||
// Barbarian's / Monk's flat mitigation is as tanky as the enemy as it is as the
|
||||
// live PC. Offense is the lossy term: the enemy path deals a flat Attack, so a
|
||||
// whole round — weapon swings (per-hit × (1 + extra attacks)) plus the expected
|
||||
// companion-strike damage — is folded into one enemy Attack, preserving expected
|
||||
// round damage while widening variance (a wash across the two orientations).
|
||||
//
|
||||
// Deliberately NOT modelled on the enemy side (accepted scope — the plan frames
|
||||
// duels as bragging rights and puts class-vs-class PvP balance explicitly out of
|
||||
// scope): reactive one-shots the enemy engine has no channel for — heal items,
|
||||
// wards, the Sovereign death-save, the arcane ward buffer — and spell damage,
|
||||
// which buildZoneCombatants never wires for the live caster either, so a caster
|
||||
// fights weapon-only in both orientations. Defense is dropped: the weapon-damage
|
||||
// path players use reads DamageReduct, not the enemy's Defense stat.
|
||||
func synthDuelEnemy(pc Combatant) Combatant {
|
||||
swings := 1 + pc.Mods.ExtraAttacks
|
||||
roundDmg := duelPerHitDamage(pc)*float64(swings) + duelPerRoundProcDamage(pc)
|
||||
return Combatant{
|
||||
Name: pc.Name,
|
||||
IsPlayer: false,
|
||||
Stats: CombatStats{
|
||||
MaxHP: pc.Stats.MaxHP,
|
||||
AC: pc.Stats.AC,
|
||||
AttackBonus: pc.Stats.AttackBonus,
|
||||
Attack: int(math.Round(roundDmg)),
|
||||
},
|
||||
Mods: CombatModifiers{DamageReduct: pc.Mods.DamageReduct},
|
||||
}
|
||||
}
|
||||
|
||||
// duelResult is the two-orientation verdict. challengerMargin/challengedMargin
|
||||
// are aggregate remaining-HP fractions across both bouts (0..2 each); the higher
|
||||
// wins. decisive is true when one fighter won both orientations outright — pure
|
||||
// narration, the margin is what decides.
|
||||
type duelResult struct {
|
||||
challengerWins bool
|
||||
draw bool
|
||||
decisive bool
|
||||
challengerFrac float64
|
||||
challengedFrac float64
|
||||
}
|
||||
|
||||
func hpFraction(end, start int) float64 {
|
||||
if start <= 0 {
|
||||
return 0
|
||||
}
|
||||
if end < 0 {
|
||||
end = 0
|
||||
}
|
||||
return float64(end) / float64(start)
|
||||
}
|
||||
|
||||
// resolveDuel runs the bout both ways and decides on the aggregate HP margin.
|
||||
// Orientation 1 seats the challenger as the live fighter against the challenged
|
||||
// player's stat block; orientation 2 swaps them. Each fighter's margin sums the
|
||||
// HP fraction they kept as the live fighter in one bout and as the stat block in
|
||||
// the other, so the attacker's-kit advantage is applied to both fighters once
|
||||
// and cancels. rng is threaded for the seeded characterization/unit tests; nil
|
||||
// is production (package-global RNG).
|
||||
func resolveDuel(challenger, challenged Combatant, rng *rand.Rand) duelResult {
|
||||
r1 := simulateCombatWithRNG(challenger, synthDuelEnemy(challenged), defaultCombatPhases, rng)
|
||||
r2 := simulateCombatWithRNG(challenged, synthDuelEnemy(challenger), defaultCombatPhases, rng)
|
||||
|
||||
cFrac := hpFraction(r1.PlayerEndHP, r1.PlayerStartHP) + hpFraction(r2.EnemyEndHP, r2.EnemyStartHP)
|
||||
dFrac := hpFraction(r1.EnemyEndHP, r1.EnemyStartHP) + hpFraction(r2.PlayerEndHP, r2.PlayerStartHP)
|
||||
|
||||
res := duelResult{challengerFrac: cFrac, challengedFrac: dFrac}
|
||||
switch {
|
||||
case cFrac > dFrac:
|
||||
res.challengerWins = true
|
||||
case dFrac > cFrac:
|
||||
res.challengerWins = false
|
||||
default:
|
||||
res.draw = true
|
||||
}
|
||||
// Decisive = the winner also took both orientations outright.
|
||||
if !res.draw {
|
||||
if res.challengerWins {
|
||||
res.decisive = r1.PlayerWon && !r2.PlayerWon
|
||||
} else {
|
||||
res.decisive = r2.PlayerWon && !r1.PlayerWon
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ── Command surface ──────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleDuelCmd(ctx MessageContext, args string) error {
|
||||
args = strings.TrimSpace(args)
|
||||
fields := strings.Fields(args)
|
||||
if len(fields) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"⚔️ **Duels** — challenge another adventurer to a staked, no-death bout.\n"+
|
||||
"`!duel @user [stake]` — throw down · `!duel accept` / `!duel decline` · `!duel status`")
|
||||
}
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "accept":
|
||||
return p.acceptDuel(ctx)
|
||||
case "decline":
|
||||
return p.declineDuel(ctx)
|
||||
case "status":
|
||||
return p.duelStatusCmd(ctx)
|
||||
default:
|
||||
return p.issueDuel(ctx, fields)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) issueDuel(ctx MessageContext, fields []string) error {
|
||||
targetID, ok := p.ResolveUser(fields[0], ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve that user. Usage: `!duel @user [stake]`")
|
||||
}
|
||||
if targetID == ctx.Sender {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't duel yourself.")
|
||||
}
|
||||
|
||||
// Serialise the one-duel gate, balance check and escrow so two rapid `!duel`
|
||||
// from the same challenger can't both pass duelInvolves and double-debit.
|
||||
lock := p.advUserLock(ctx.Sender)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
challenger, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil || challenger == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer. Type `!adventure` to create one.")
|
||||
}
|
||||
lvl, reason := p.duelEligible(challenger)
|
||||
if reason != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason))
|
||||
}
|
||||
|
||||
challenged, err := loadAdvCharacter(targetID)
|
||||
if err != nil || challenged == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no adventurer to duel.", p.DisplayName(targetID)))
|
||||
}
|
||||
if _, reason := p.duelEligible(challenged); reason != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("%s can't duel right now (%s).", p.DisplayName(targetID), reason))
|
||||
}
|
||||
if challenged.BabysitActive {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("%s left word with the Babysitting Service — no duels while they're away.", p.DisplayName(targetID)))
|
||||
}
|
||||
|
||||
// One duel at a time per player, either side — the escrow makes overlaps a
|
||||
// double-commit of the same euros.
|
||||
if duelInvolves(ctx.Sender) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You already have a duel in flight. Resolve it first.")
|
||||
}
|
||||
if duelInvolves(targetID) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s already has a duel in flight.", p.DisplayName(targetID)))
|
||||
}
|
||||
|
||||
// Shared 7-day per-pair cooldown across both duel kinds (records table).
|
||||
if last := lastDuelBetween(ctx.Sender, targetID); !last.IsZero() && time.Since(last) < rivalSamePairCooldown {
|
||||
wait := rivalSamePairCooldown - time.Since(last)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You've dueled %s too recently. Try again %s.", p.DisplayName(targetID), formatDuration(wait)))
|
||||
}
|
||||
|
||||
stake, err := p.parseDuelStake(fields, lvl)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, err.Error())
|
||||
}
|
||||
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < stake {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You can't cover a €%d stake — you have €%d.", stake, int(bal)))
|
||||
}
|
||||
|
||||
// Escrow the challenger's stake now (debited to nowhere; the pot/winner split
|
||||
// redistributes it on resolution, and expiry/decline refunds it).
|
||||
if !p.euro.Debit(ctx.Sender, float64(stake), "duel_escrow") {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. Try again.")
|
||||
}
|
||||
|
||||
ch := &advDuelChallenge{
|
||||
ChallengeID: uuid.NewString(),
|
||||
ChallengerID: ctx.Sender,
|
||||
ChallengedID: targetID,
|
||||
Stake: stake,
|
||||
ExpiresAt: time.Now().UTC().Add(duelAcceptWindow),
|
||||
}
|
||||
insertDuelChallenge(ch)
|
||||
|
||||
p.SendDM(targetID, fmt.Sprintf(
|
||||
"⚔️ **%s challenges you to a duel!**\n\nStake: €%d each — winner takes 70%% of the pot.\n"+
|
||||
"No death, no hospital bill. Just bragging rights.\n\n`!duel accept` or `!duel decline` — you have 24 hours.",
|
||||
p.DisplayName(ctx.Sender), stake))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"⚔️ You throw down the gauntlet before **%s** for €%d. They have 24 hours to answer.",
|
||||
p.DisplayName(targetID), stake))
|
||||
}
|
||||
|
||||
// parseDuelStake reads the optional stake argument, defaulting to the cap for
|
||||
// the challenger's level and clamping into [duelMinStake, duelMaxStake].
|
||||
func (p *AdventurePlugin) parseDuelStake(fields []string, level int) (int, error) {
|
||||
maxStake := duelMaxStake(level)
|
||||
if len(fields) < 2 {
|
||||
return maxStake, nil
|
||||
}
|
||||
raw := strings.TrimPrefix(fields[1], "€")
|
||||
stake := 0
|
||||
if _, err := fmt.Sscanf(raw, "%d", &stake); err != nil || stake <= 0 {
|
||||
return 0, fmt.Errorf("that's not a valid stake. Usage: `!duel @user [stake]`")
|
||||
}
|
||||
if stake < duelMinStake {
|
||||
return 0, fmt.Errorf("minimum stake is €%d", duelMinStake)
|
||||
}
|
||||
if stake > maxStake {
|
||||
return 0, fmt.Errorf("your stake is capped at €%d (level %d × €%d)", maxStake, level, duelStakePerLvl)
|
||||
}
|
||||
return stake, nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) acceptDuel(ctx MessageContext) error {
|
||||
// Serialise a player's own accept so a double-submit can't debit twice.
|
||||
lock := p.advUserLock(ctx.Sender)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
ch := pendingDuelForChallenged(ctx.Sender)
|
||||
if ch == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to accept.")
|
||||
}
|
||||
// Cheap validation first — these leave the challenge open (the player can
|
||||
// revive/earn and retry, or decline to release the challenger's escrow).
|
||||
challenged, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil || challenged == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer.")
|
||||
}
|
||||
if _, reason := p.duelEligible(challenged); reason != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason))
|
||||
}
|
||||
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < ch.Stake {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You can't cover the €%d stake — you have €%d. `!duel decline` to back out.", ch.Stake, int(bal)))
|
||||
}
|
||||
|
||||
// The challenger may have died or dropped below the gate during the 24h
|
||||
// window. If so, cancel the duel: claim the row (so the ticker/decline can't
|
||||
// also refund) and hand the challenger their escrow back.
|
||||
challenger, cerr := loadAdvCharacter(ch.ChallengerID)
|
||||
if cerr != nil || challenger == nil {
|
||||
if claimDuelChallenge(ch.ChallengeID) {
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_gone")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Your challenger is no longer available. The duel is off.")
|
||||
}
|
||||
if _, reason := p.duelEligible(challenger); reason != "" {
|
||||
if claimDuelChallenge(ch.ChallengeID) {
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_ineligible")
|
||||
p.SendDM(ch.ChallengerID, fmt.Sprintf(
|
||||
"⚔️ Your duel with **%s** was called off — you can't fight right now. Your €%d stake is refunded.",
|
||||
p.DisplayName(ctx.Sender), ch.Stake))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("**%s** can't duel right now (%s). The challenge is off.", p.DisplayName(ch.ChallengerID), reason))
|
||||
}
|
||||
|
||||
// Build both fighters BEFORE debiting the challenged player, so the deep
|
||||
// sheet-loading / combat code (the panic-prone part) runs while nobody but
|
||||
// the challenger is committed. A build failure refunds only the challenger.
|
||||
challengerC, err1 := p.buildDuelist(ch.ChallengerID)
|
||||
challengedC, err2 := p.buildDuelist(ctx.Sender)
|
||||
if err1 != nil || err2 != nil {
|
||||
if claimDuelChallenge(ch.ChallengeID) {
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "The duel couldn't be staged. The challenger's stake is refunded.")
|
||||
}
|
||||
|
||||
// Commit: claim the row (exactly one path wins), then take the challenged
|
||||
// player's stake. From here both stakes are held and resolution is pure
|
||||
// arithmetic — no further build or DB read that could panic mid-payout.
|
||||
if !claimDuelChallenge(ch.ChallengeID) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "That duel is no longer available.")
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(ch.Stake), "duel_escrow") {
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. The duel is off.")
|
||||
}
|
||||
|
||||
return p.settleDuel(ctx, ch, challengerC, challengedC)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) declineDuel(ctx MessageContext) error {
|
||||
// Same lock as accept so a player's own accept/decline can't interleave; the
|
||||
// claim is the real guard, but the lock keeps the read→claim window tight.
|
||||
lock := p.advUserLock(ctx.Sender)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
ch := pendingDuelForChallenged(ctx.Sender)
|
||||
if ch == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.")
|
||||
}
|
||||
if !claimDuelChallenge(ch.ChallengeID) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.")
|
||||
}
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_declined")
|
||||
p.SendDM(ch.ChallengerID, fmt.Sprintf(
|
||||
"🛡️ **%s** declined your duel. Your €%d stake is refunded.", p.DisplayName(ctx.Sender), ch.Stake))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You decline the duel. No harm done.")
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) duelStatusCmd(ctx MessageContext) error {
|
||||
if ch := pendingDuelForChallenged(ctx.Sender); ch != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"⚔️ **%s** has challenged you for €%d. `!duel accept` / `!duel decline` (expires %s).",
|
||||
p.DisplayName(ch.ChallengerID), ch.Stake, formatDuration(time.Until(ch.ExpiresAt))))
|
||||
}
|
||||
// Any challenge we issued that's still pending?
|
||||
c := &advDuelChallenge{}
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
|
||||
FROM adventure_duel_challenges
|
||||
WHERE challenger_id = ? AND expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY created_at DESC LIMIT 1`, string(ctx.Sender)).Scan(
|
||||
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
|
||||
if err == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||
"⚔️ You've challenged **%s** for €%d. Waiting on their answer (expires %s).",
|
||||
p.DisplayName(c.ChallengedID), c.Stake, formatDuration(time.Until(c.ExpiresAt))))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no duels in flight. `!duel @user [stake]` to start one.")
|
||||
}
|
||||
|
||||
// ── Resolution ───────────────────────────────────────────────────────────────
|
||||
|
||||
// settleDuel resolves the two-orientation bout, splits the pooled 2×stake,
|
||||
// records the result, and DMs both players. By the time it runs the challenge
|
||||
// row is claimed, both fighters are built, and both stakes are held — so from
|
||||
// here it is pure arithmetic and message sends, nothing that can panic and
|
||||
// strand the pool. On an exact HP-margin tie both stakes are refunded.
|
||||
func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, challengerC, challengedC Combatant) error {
|
||||
res := resolveDuel(challengerC, challengedC, nil)
|
||||
|
||||
if res.draw {
|
||||
// Exact HP-margin tie: refund both, record nothing, no cooldown burned.
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_draw")
|
||||
p.euro.Credit(ch.ChallengedID, float64(ch.Stake), "duel_refund_draw")
|
||||
draw := fmt.Sprintf("⚔️ **%s** and **%s** fought to a standstill. Both stakes refunded.",
|
||||
p.DisplayName(ch.ChallengerID), p.DisplayName(ch.ChallengedID))
|
||||
p.SendDM(ch.ChallengerID, draw)
|
||||
p.SendDM(ch.ChallengedID, draw)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, draw)
|
||||
}
|
||||
|
||||
winnerID, loserID := ch.ChallengedID, ch.ChallengerID
|
||||
if res.challengerWins {
|
||||
winnerID, loserID = ch.ChallengerID, ch.ChallengedID
|
||||
}
|
||||
|
||||
winnerShare, potShare := duelPayout(ch.Stake)
|
||||
p.euro.Credit(winnerID, float64(winnerShare), "duel_win")
|
||||
if potShare > 0 {
|
||||
communityPotAdd(potShare)
|
||||
// The rake came out of a pool both funded; attribute it half each.
|
||||
trackTaxPaid(winnerID, potShare/2)
|
||||
trackTaxPaid(loserID, potShare-potShare/2)
|
||||
}
|
||||
|
||||
upsertRivalRecord(winnerID, loserID, true)
|
||||
upsertRivalRecord(loserID, winnerID, false)
|
||||
|
||||
// One BULLETIN dispatch per settled duel (from the winner's side, so it
|
||||
// fires once). Character names only; no-op unless the seam is enabled.
|
||||
if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" {
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("rival:%d", ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), ts),
|
||||
EventType: "rival_result",
|
||||
Tier: "bulletin",
|
||||
Subject: wn,
|
||||
Opponent: ln,
|
||||
Outcome: "won",
|
||||
OccurredAt: ts,
|
||||
}, winnerID, loserID)
|
||||
}
|
||||
|
||||
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceDuel(ctx MessageContext, winnerID, loserID id.UserID, stake, winnerShare, potShare int, decisive bool) {
|
||||
winName, loseName := p.DisplayName(winnerID), p.DisplayName(loserID)
|
||||
flair := pickDuelFlavor(duelWinFlavor)
|
||||
if decisive {
|
||||
flair = pickDuelFlavor(duelDecisiveFlavor)
|
||||
}
|
||||
|
||||
p.SendDM(winnerID, fmt.Sprintf(
|
||||
"⚔️ **You beat %s!**\n\n*%s*\n\nYou take €%d (net +€%d). €%d went to the community pot.",
|
||||
loseName, flair, winnerShare, winnerShare-stake, potShare))
|
||||
p.SendDM(loserID, fmt.Sprintf(
|
||||
"⚔️ **%s bested you.**\n\n*%s*\n\nYou're down €%d — but you walk away, no worse for wear.",
|
||||
winName, pickDuelFlavor(duelLossFlavor), stake))
|
||||
|
||||
// Broadcast the result to the games room for bragging rights — unless the
|
||||
// duel was accepted there, in which case the SendReply below already covers
|
||||
// it and a second line would just be noise.
|
||||
if gr := gamesRoom(); gr != "" && gr != ctx.RoomID {
|
||||
verb := "defeated"
|
||||
if decisive {
|
||||
verb = "decisively defeated"
|
||||
}
|
||||
p.SendMessage(gr, fmt.Sprintf("⚔️ **%s** %s **%s** in a duel for €%d.", winName, verb, loseName, stake))
|
||||
}
|
||||
// Confirm in the room the challenge was accepted from too.
|
||||
p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("⚔️ **%s** wins the duel, taking €%d.", winName, winnerShare))
|
||||
}
|
||||
|
||||
// expireDuelChallenges refunds and clears any challenge whose 24h window lapsed.
|
||||
// Rides the 1-minute event ticker (no new goroutine); only the challenger was
|
||||
// escrowed, so only they are refunded.
|
||||
func (p *AdventurePlugin) expireDuelChallenges() {
|
||||
for _, ch := range loadExpiredDuelChallenges() {
|
||||
// Claim before refunding: if an accept/decline already took this row at
|
||||
// the 24h boundary, we must not also refund the challenger.
|
||||
if !claimDuelChallenge(ch.ChallengeID) {
|
||||
continue
|
||||
}
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_expired")
|
||||
p.SendDM(ch.ChallengerID, fmt.Sprintf(
|
||||
"⌛ **%s** never answered your duel. Your €%d stake is refunded.",
|
||||
p.DisplayName(ch.ChallengedID), ch.Stake))
|
||||
}
|
||||
}
|
||||
|
||||
func duelIneligibleSelf(reason string) string {
|
||||
switch reason {
|
||||
case "dead":
|
||||
return "You can't duel while dead. Visit `!hospital` first."
|
||||
case "no adventurer":
|
||||
return "You have no adventurer. Type `!adventure` to create one."
|
||||
default:
|
||||
return fmt.Sprintf("You need to be at least level %d to duel.", duelMinLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor ───────────────────────────────────────────────────────────────────
|
||||
|
||||
var duelWinFlavor = []string{
|
||||
"Steel rang, and the crowd knew its champion.",
|
||||
"A clean finish — you left them nothing to answer with.",
|
||||
"They fought well. You fought better.",
|
||||
}
|
||||
|
||||
var duelDecisiveFlavor = []string{
|
||||
"It was over before it began. Utterly one-sided.",
|
||||
"You never gave them a foothold. A rout.",
|
||||
"The arena barely had time to hush.",
|
||||
}
|
||||
|
||||
var duelLossFlavor = []string{
|
||||
"You'll want that one back. Next time.",
|
||||
"A hard lesson — but only your pride is bleeding.",
|
||||
"Close, in places. Not close enough.",
|
||||
}
|
||||
|
||||
func pickDuelFlavor(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
274
internal/plugin/adventure_duel_test.go
Normal file
274
internal/plugin/adventure_duel_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newDuelTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db.Close()
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// duelSeed builds a deterministic RNG so the two-orientation resolver is
|
||||
// reproducible across runs — the production path passes nil (global RNG).
|
||||
func duelSeed(i uint64) *rand.Rand { return rand.New(rand.NewPCG(0x1d4e11, i)) }
|
||||
|
||||
func TestDuelPayout(t *testing.T) {
|
||||
// Both escrow 2000 → pool 4000 → winner 70% (2800), pot 30% (1200).
|
||||
w, pot := duelPayout(2000)
|
||||
if w != 2800 || pot != 1200 {
|
||||
t.Fatalf("duelPayout(2000) = (%d, %d), want (2800, 1200)", w, pot)
|
||||
}
|
||||
// The pot always gets exactly what the winner doesn't; nothing is minted
|
||||
// or lost against the pooled 2×stake.
|
||||
for _, stake := range []int{100, 137, 999, 6000} {
|
||||
w, pot := duelPayout(stake)
|
||||
if w+pot != stake*2 {
|
||||
t.Errorf("duelPayout(%d): winner+pot = %d, want pool %d", stake, w+pot, stake*2)
|
||||
}
|
||||
if w <= pot {
|
||||
t.Errorf("duelPayout(%d): winner %d should exceed pot %d", stake, w, pot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuelMaxStake(t *testing.T) {
|
||||
if got := duelMaxStake(5); got != 2500 {
|
||||
t.Fatalf("duelMaxStake(5) = %d, want 2500", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuelPerHitDamage(t *testing.T) {
|
||||
// 1d8 (avg 4.5) + ability mod 3 = 7.5 base, + Divine Strike 2 = 9.5,
|
||||
// × (1 + 0.25 damage bonus) = 11.875.
|
||||
pc := Combatant{
|
||||
Stats: CombatStats{
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
|
||||
AbilityModForDamage: 3,
|
||||
},
|
||||
Mods: CombatModifiers{DivineStrikePerHit: 2, DamageBonus: 0.25},
|
||||
}
|
||||
got := duelPerHitDamage(pc)
|
||||
if math.Abs(got-11.875) > 1e-9 {
|
||||
t.Fatalf("duelPerHitDamage = %v, want 11.875", got)
|
||||
}
|
||||
|
||||
// Weaponless build falls back to the flat Attack stat.
|
||||
bare := Combatant{Stats: CombatStats{Attack: 9}}
|
||||
if got := duelPerHitDamage(bare); got != 9 {
|
||||
t.Fatalf("weaponless duelPerHitDamage = %v, want 9", got)
|
||||
}
|
||||
|
||||
// A build that computes to <1 is floored to 1 (a fight always deals damage).
|
||||
weak := Combatant{Stats: CombatStats{Attack: 0}}
|
||||
if got := duelPerHitDamage(weak); got != 1 {
|
||||
t.Fatalf("floored duelPerHitDamage = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthDuelEnemy(t *testing.T) {
|
||||
pc := Combatant{
|
||||
Name: "Aria",
|
||||
IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 123,
|
||||
AC: 17,
|
||||
AttackBonus: 8,
|
||||
Defense: 5,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
|
||||
AbilityModForDamage: 3,
|
||||
},
|
||||
Mods: CombatModifiers{ExtraAttacks: 1}, // two swings/round
|
||||
}
|
||||
e := synthDuelEnemy(pc)
|
||||
|
||||
if e.IsPlayer {
|
||||
t.Error("synthesised enemy must not be flagged IsPlayer")
|
||||
}
|
||||
if e.Ability != nil {
|
||||
t.Error("v1 synth enemy carries no MonsterAbility")
|
||||
}
|
||||
// HP / AC / to-hit carry over verbatim: to-hit stays faithful both ways.
|
||||
if e.Stats.MaxHP != 123 || e.Stats.AC != 17 || e.Stats.AttackBonus != 8 {
|
||||
t.Errorf("stat carryover wrong: got HP=%d AC=%d AB=%d", e.Stats.MaxHP, e.Stats.AC, e.Stats.AttackBonus)
|
||||
}
|
||||
// Damage: per-hit (7.5) × swings (2) folded into one flat Attack = 15.
|
||||
if e.Stats.Attack != 15 {
|
||||
t.Errorf("folded Attack = %d, want 15 (7.5 × 2 swings)", e.Stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDuel_StrongerWinsRegardlessOfRole pins that a clearly superior
|
||||
// build wins whether it issues or receives the challenge — the two-orientation
|
||||
// resolver must not hand the win to the attacker seat.
|
||||
func TestResolveDuel_StrongerWinsRegardlessOfRole(t *testing.T) {
|
||||
strong := Combatant{
|
||||
Name: "Champion", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 220, AC: 19, AttackBonus: 12,
|
||||
Weapon: &WeaponProfile{DamageCount: 2, DamageSides: 8}, AbilityModForDamage: 5, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
weak := Combatant{
|
||||
Name: "Novice", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 45, AC: 11, AttackBonus: 3,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 4}, AbilityModForDamage: 0, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
|
||||
for i := uint64(0); i < 50; i++ {
|
||||
if r := resolveDuel(strong, weak, duelSeed(i)); r.draw || !r.challengerWins {
|
||||
t.Fatalf("seed %d: strong-as-challenger should win, got %+v", i, r)
|
||||
}
|
||||
if r := resolveDuel(weak, strong, duelSeed(i)); r.draw || r.challengerWins {
|
||||
t.Fatalf("seed %d: strong-as-challenged should win, got %+v", i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDuel_IdenticalFightersAreUnbiased is the fairness guarantee that
|
||||
// justifies running both orientations: with two identical builds neither the
|
||||
// challenger nor the challenged seat may have a systematic edge. A single-
|
||||
// orientation model (attacker uses full kit, defender is a stat block) would
|
||||
// skew hard toward the challenger; the both-orientations margin cancels it.
|
||||
func TestResolveDuel_IdenticalFightersAreUnbiased(t *testing.T) {
|
||||
fighter := Combatant{
|
||||
Name: "Mirror", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 110, AC: 15, AttackBonus: 7,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 3, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
|
||||
const n = 1200
|
||||
challengerWins, draws := 0, 0
|
||||
for i := uint64(0); i < n; i++ {
|
||||
r := resolveDuel(fighter, fighter, duelSeed(i))
|
||||
switch {
|
||||
case r.draw:
|
||||
draws++
|
||||
case r.challengerWins:
|
||||
challengerWins++
|
||||
}
|
||||
}
|
||||
decided := n - draws
|
||||
if decided == 0 {
|
||||
t.Fatal("all identical-fighter duels drew — resolver isn't discriminating")
|
||||
}
|
||||
// Unbiased by construction (challenger wins iff D1 > D2 for iid D); allow a
|
||||
// generous ±10% band so this stays a bias check, not a flaky coin-count.
|
||||
rate := float64(challengerWins) / float64(decided)
|
||||
if rate < 0.40 || rate > 0.60 {
|
||||
t.Fatalf("challenger win rate %.3f over %d decided duels — systematic bias (want ~0.5)", rate, decided)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDuelChallenge pins the serialization primitive that guards every
|
||||
// euro path: exactly one caller may claim a challenge, so accept/decline/expire
|
||||
// can never both move money for the same row.
|
||||
func TestClaimDuelChallenge(t *testing.T) {
|
||||
newDuelTestDB(t)
|
||||
ch := &advDuelChallenge{
|
||||
ChallengeID: "duel-1",
|
||||
ChallengerID: id.UserID("@a:x"),
|
||||
ChallengedID: id.UserID("@b:x"),
|
||||
Stake: 1000,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
insertDuelChallenge(ch)
|
||||
|
||||
if pendingDuelForChallenged(id.UserID("@b:x")) == nil {
|
||||
t.Fatal("challenge should be pending for the challenged player before any claim")
|
||||
}
|
||||
if !duelInvolves(id.UserID("@a:x")) || !duelInvolves(id.UserID("@b:x")) {
|
||||
t.Fatal("duelInvolves should see both parties while the challenge is live")
|
||||
}
|
||||
|
||||
if !claimDuelChallenge("duel-1") {
|
||||
t.Fatal("first claim must succeed")
|
||||
}
|
||||
if claimDuelChallenge("duel-1") {
|
||||
t.Fatal("second claim must fail — the row is gone, so no double refund/payout")
|
||||
}
|
||||
if pendingDuelForChallenged(id.UserID("@b:x")) != nil {
|
||||
t.Fatal("claimed challenge must no longer be pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExpiredDuelChallenges(t *testing.T) {
|
||||
newDuelTestDB(t)
|
||||
insertDuelChallenge(&advDuelChallenge{
|
||||
ChallengeID: "live", ChallengerID: "@a:x", ChallengedID: "@b:x",
|
||||
Stake: 500, ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
insertDuelChallenge(&advDuelChallenge{
|
||||
ChallengeID: "stale", ChallengerID: "@c:x", ChallengedID: "@d:x",
|
||||
Stake: 500, ExpiresAt: time.Now().UTC().Add(-time.Hour),
|
||||
})
|
||||
got := loadExpiredDuelChallenges()
|
||||
if len(got) != 1 || got[0].ChallengeID != "stale" {
|
||||
t.Fatalf("loadExpiredDuelChallenges = %+v, want only the stale one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthDuelEnemy_PortsDefenseAndProcs pins the fidelity carry-overs added
|
||||
// after review: a tank's DamageReduct rides onto the synth enemy, and companion
|
||||
// procs fold their expected damage into its round Attack.
|
||||
func TestSynthDuelEnemy_PortsDefenseAndProcs(t *testing.T) {
|
||||
pc := Combatant{
|
||||
Stats: CombatStats{
|
||||
MaxHP: 100, AC: 15, AttackBonus: 6,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 2, // 6.5/hit
|
||||
},
|
||||
Mods: CombatModifiers{
|
||||
DamageReduct: 0.6, // Barbarian-style mitigation
|
||||
SpiritWeaponProc: 0.5, SpiritWeaponDmg: 4, // expected 0.5×(4+3)=3.5/round
|
||||
},
|
||||
}
|
||||
e := synthDuelEnemy(pc)
|
||||
if e.Mods.DamageReduct != 0.6 {
|
||||
t.Errorf("DamageReduct not ported: got %v, want 0.6", e.Mods.DamageReduct)
|
||||
}
|
||||
// One swing (6.5) + proc expectation (3.5) = 10 → rounded 10.
|
||||
if e.Stats.Attack != 10 {
|
||||
t.Errorf("folded Attack = %d, want 10 (6.5 weapon + 3.5 proc)", e.Stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuelStake(t *testing.T) {
|
||||
p := &AdventurePlugin{}
|
||||
const level = 5 // cap = 2500
|
||||
|
||||
// No stake → default to the cap.
|
||||
if got, err := p.parseDuelStake([]string{"@u"}, level); err != nil || got != 2500 {
|
||||
t.Fatalf("default stake = (%d, %v), want (2500, nil)", got, err)
|
||||
}
|
||||
// Explicit in-band stake, with and without the € prefix.
|
||||
if got, err := p.parseDuelStake([]string{"@u", "€1500"}, level); err != nil || got != 1500 {
|
||||
t.Fatalf("€1500 stake = (%d, %v), want (1500, nil)", got, err)
|
||||
}
|
||||
// Over the cap is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "9999"}, level); err == nil {
|
||||
t.Error("stake above cap should error")
|
||||
}
|
||||
// Below the floor is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "10"}, level); err == nil {
|
||||
t.Error("stake below the minimum should error")
|
||||
}
|
||||
// Garbage is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "lots"}, level); err == nil {
|
||||
t.Error("non-numeric stake should error")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,14 @@ func (p *AdventurePlugin) eventTicker() {
|
||||
// Reclaim invites nobody answered (N3/P6b). Every read already
|
||||
// filters on the TTL; this just stops the rows accumulating.
|
||||
purgeExpiredInvites()
|
||||
|
||||
// World boss (N6/C3): auto-spawn the monthly Siege on the 1st and
|
||||
// resolve one whose 72h window has lapsed. Own dedup inside.
|
||||
p.worldBossTick()
|
||||
|
||||
// Duels (N6/C2): refund and clear any challenge whose 24h accept
|
||||
// window lapsed.
|
||||
p.expireDuelChallenges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
519
internal/plugin/adventure_flavor_campaign.go
Normal file
519
internal/plugin/adventure_flavor_campaign.go
Normal file
@@ -0,0 +1,519 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The Hollow King campaign (N5/D1). A light serialized story threaded through
|
||||
// the zones by way of collectible journal pages. "The Hollow King" is already
|
||||
// the Forest Shadows (T2) boss; this frames him as a realm-spanning antagonist
|
||||
// whose fragments turn up wherever players fight elites and open secret rooms.
|
||||
//
|
||||
// The fragments are in-world found artifacts — journal entries, torn letters,
|
||||
// stone inscriptions — not TwinBee's voice. TwinBee's own reactions (D1b) obey
|
||||
// the first-person voice rules; this text does not.
|
||||
|
||||
type journalPage struct {
|
||||
Title string
|
||||
Text string
|
||||
}
|
||||
|
||||
// journalPages is the ordered campaign. Reading the discovered pages top to
|
||||
// bottom tells the fall of a kingdom to the thing its king became. Order is the
|
||||
// story order; players find them out of sequence, which is why the viewer marks
|
||||
// gaps.
|
||||
var journalPages = []journalPage{
|
||||
{"The Long Winter", "A crown is only a circle of gold until a man decides never to take it off. Ours decided in a winter that would not end, and the frost took its cue from him."},
|
||||
{"The Last Physician", "The court healers were sent home one by one, each promising the king had years left. The last was not sent home. We heard him thank the king for the honour."},
|
||||
{"On Shadows", "He stopped casting a shadow before he stopped casting a reflection. The steward struck both from the list of things a servant may notice aloud."},
|
||||
{"The Quiet Ledger", "Grain still left the granaries; no mouths were fed. I stopped auditing the difference the night the number began to feel like a name."},
|
||||
{"A Bargain Overheard", "Through the chapel door: the king's voice, and a second that used his own words a breath before he did. Only one of them was asking."},
|
||||
{"The Hollowing", "They call it a coronation in the records. Those of us who carried the braziers call it what it was. A king was emptied so a crown could keep wearing him."},
|
||||
{"The First Knights", "His honour guard did not die. That was the mercy offered and the price paid. They stand at the old gate yet, and they still salute a throne no living thing sits on."},
|
||||
{"Letters Home, Unsent", "\"Tell mother the pay is good and the work is guarding.\" The satchel held forty such letters, every hand different, every promise the same, none of them sent."},
|
||||
{"The Map Redrawn", "The kingdom did not fall so much as come apart at the seams — a warren here, a drowned temple there, each piece keeping a splinter of him like a tooth in a wound."},
|
||||
{"Root and Rot", "The forest north of the manor grew wrong and grew fast. The woodsmen say the trees lean toward the ruin at dusk. The woodsmen no longer go at dusk."},
|
||||
{"The Warren Below", "Even the goblins gave the deep tunnels to him and asked nothing back. When a scavenger yields ground for free, ask what it saw down there."},
|
||||
{"Water That Remembers", "The temple sank in a single night with the bells still ringing. Divers say the bells ring still, slow, as if something below is counting."},
|
||||
{"The Manor's Long Guest", "Blackspire changed hands nine times in a decade. Every deed names a different owner. Every household names the same tenant, and none will write it down."},
|
||||
{"The Forge Unbanked", "The underforge keeps a heat with no fuel and no smith. What it makes, no one has seen leave. What it makes, we are told, was promised elsewhere long ago."},
|
||||
{"Descent", "The deep roads were a trade route once. Now they are a throat. Everything the surface loses is swallowed the same direction, and the direction has a door at the end."},
|
||||
{"The Bright Country", "Past the crossing the colours are too kind and the days too long. It is the most beautiful place I have run from. He is patient there; he can afford to be."},
|
||||
{"What the Dragon Keeps", "The wyrm hoards more than gold. Deep in the lair, behind the coin, a single crown sits on no head and is guarded better than the hoard."},
|
||||
{"The Portal's Arithmetic", "The abyss gate opens outward. Everyone assumes a door lets things in. This one was built by someone who only ever intended to leave through it."},
|
||||
{"The Regent's Confession", "I ruled in his name for thirty years and never once saw him rule. I signed what the crown wanted signed. I am writing this so that one honest page exists."},
|
||||
{"The Names of the Guard", "I have set down every knight's name here so that when this is read, they are grieved as men and not feared as things. It is the only rescue left to attempt."},
|
||||
{"The Flaw in the Bargain", "The second voice took the king's life and his death both — and a thing that cannot die also cannot be finished. He is not immortal. He is unpaid, and waiting to collect."},
|
||||
{"How to Call Him", "He answers only where his fragments gather and only to one who has gathered them. Do not do this to avenge us. Do it to end the account. Come with the whole ledger or do not come."},
|
||||
{"The Empty Throne", "I have seen the seat at the heart of it all. It is not empty. It is occupied by the shape of a man who left, kept warm against his return."},
|
||||
{"Last Page", "If you are reading in order, you have walked the ruin of everything he emptied to stay. One page is missing from every telling — the one you write by going in. Bring a light. He hates the light. It is the one thing he could never hollow out."},
|
||||
}
|
||||
|
||||
// journalTotalPages is the campaign length; the drop/viewer/finale all read it
|
||||
// so the story can grow by appending to journalPages alone.
|
||||
var journalTotalPages = len(journalPages)
|
||||
|
||||
// journalPageDropChance is the per-elite-kill probability that a page turns up.
|
||||
// Deliberately modest: 24 pages is a long-horizon collection, and secret rooms
|
||||
// (D4) grant pages on top of this. Tunable.
|
||||
const journalPageDropChance = 0.22
|
||||
|
||||
// setJournalPageBit is the in-memory twin of grantJournalPageDB's bitwise OR —
|
||||
// the single definition of "page N lives in bit N-1". Out-of-range pages are a
|
||||
// no-op.
|
||||
func setJournalPageBit(mask int64, page int) int64 {
|
||||
if page < 1 || page > 63 {
|
||||
return mask
|
||||
}
|
||||
return mask | (int64(1) << (page - 1))
|
||||
}
|
||||
|
||||
func journalPageFound(mask int64, page int) bool {
|
||||
if page < 1 || page > 63 {
|
||||
return false
|
||||
}
|
||||
return mask&(int64(1)<<(page-1)) != 0
|
||||
}
|
||||
|
||||
func journalPageCount(mask int64) int {
|
||||
n := 0
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if journalPageFound(mask, i) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func journalComplete(mask int64) bool {
|
||||
return journalPageCount(mask) >= journalTotalPages
|
||||
}
|
||||
|
||||
// pickUnfoundJournalPage returns a random not-yet-found page number, or 0 when
|
||||
// the campaign is already complete.
|
||||
func pickUnfoundJournalPage(mask int64, rng *rand.Rand) int {
|
||||
var missing []int
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if !journalPageFound(mask, i) {
|
||||
missing = append(missing, i)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return 0
|
||||
}
|
||||
return missing[rngIntN(rng, len(missing))]
|
||||
}
|
||||
|
||||
// maybeDropJournalPage rolls a page reward for an elite kill or secret room and,
|
||||
// on a hit, grants a random unfound page and returns its narration line. Empty
|
||||
// string means no drop (missed the roll, DB error, or campaign already
|
||||
// complete). The roll draws from the same RNG the surrounding loot rolls use
|
||||
// and never touches SimulateCombat's stream, so the combat golden is unmoved.
|
||||
func (p *AdventurePlugin) maybeDropJournalPage(userID id.UserID, rng *rand.Rand) string {
|
||||
if rngFloat(rng) >= journalPageDropChance {
|
||||
return ""
|
||||
}
|
||||
return p.grantJournalPage(userID, rng)
|
||||
}
|
||||
|
||||
// grantJournalPage grants a random unfound page unconditionally (used by secret
|
||||
// rooms, which award a page for certain). Returns "" when already complete or on
|
||||
// error.
|
||||
func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) string {
|
||||
mask, err := loadJournalPages(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
page := pickUnfoundJournalPage(mask, rng)
|
||||
if page == 0 {
|
||||
return ""
|
||||
}
|
||||
if err := grantJournalPageDB(userID, page); err != nil {
|
||||
return ""
|
||||
}
|
||||
// If the page turned up mid-expedition, drop a log beat so the end-of-day
|
||||
// digest can have TwinBee react to it. No expedition (legacy !zone, or a
|
||||
// secret room opened outside a run) simply means no digest to react in.
|
||||
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "journal",
|
||||
journalPages[page-1].Title, fmt.Sprintf("page %d", page))
|
||||
}
|
||||
return fmt.Sprintf("📖 A torn journal page — _%s_ (page %d of %d). See `!adventure journal`.",
|
||||
journalPages[page-1].Title, page, journalTotalPages)
|
||||
}
|
||||
|
||||
// --- N5/D4 secret content pass ---------------------------------------------
|
||||
//
|
||||
// Every NodeKindSecret room resolves as a no-combat treasure cache (see
|
||||
// resolveSecretRoom in dnd_zone_combat.go). Finding it — via the perception /
|
||||
// stat check on the fork, or a cross-zone key — is the reward: a guaranteed
|
||||
// journal page, a LootBias-weighted treasure roll, and a guaranteed consumable
|
||||
// cache. The flavor and the key catalog live here with the rest of the
|
||||
// campaign; the resolution mechanics live next to the other room resolvers.
|
||||
|
||||
// secretRoomCacheCount is how many zone-tier consumables a secret room always
|
||||
// hands over, so the room pays out something visible even for a player who has
|
||||
// every page and misses the treasure roll.
|
||||
const secretRoomCacheCount = 2
|
||||
|
||||
// crossZoneKey pairs a source secret room with the key item it grants. The key
|
||||
// is a plain inventory item; a LockKey edge in the destination zone matches its
|
||||
// lower-cased Name against lock_data.key_id (see evaluateEdgeLock). Keys persist
|
||||
// in inventory, so the unlock is permanent once earned.
|
||||
type crossZoneKey struct {
|
||||
item AdvItem
|
||||
unlocksIn ZoneID // destination zone, for the grant narration
|
||||
unlockHint string // what the key opens, surfaced when it's granted
|
||||
}
|
||||
|
||||
// secretRoomKeys — the two cross-zone keys (N5/D4). A Sunken Temple sigil opens
|
||||
// a sealed reliquary in Manor Blackspire; an Underforge seal opens a vault deep
|
||||
// in the Underdark throne approach. Keyed by the source secret's node ID.
|
||||
var secretRoomKeys = map[string]crossZoneKey{
|
||||
"sunken_temple.coral_reliquary": {
|
||||
item: AdvItem{Name: "Sunken Sigil", Type: "key", Tier: 2},
|
||||
unlocksIn: ZoneManorBlackspire,
|
||||
unlockHint: "a drowned god's mark — Blackspire has a door that remembers it",
|
||||
},
|
||||
"underforge.forge_vault": {
|
||||
item: AdvItem{Name: "Underforge Seal", Type: "key", Tier: 3},
|
||||
unlocksIn: ZoneUnderdark,
|
||||
unlockHint: "the forge's own brand — something in the deep roads was promised its work",
|
||||
},
|
||||
}
|
||||
|
||||
// grantSecretRoomKey hands over the cross-zone key a secret room carries, if it
|
||||
// carries one and the player isn't already holding it. Idempotent: re-finding
|
||||
// the room on a later run won't stack duplicate keys. Returns the narration line
|
||||
// (empty when the room grants no key, the player already has it, or on error).
|
||||
func (p *AdventurePlugin) grantSecretRoomKey(userID id.UserID, node ZoneNode) string {
|
||||
key, ok := secretRoomKeys[node.NodeID]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
want := strings.ToLower(key.item.Name)
|
||||
for _, it := range items {
|
||||
if strings.ToLower(it.Name) == want {
|
||||
return "" // already earned — don't stack it
|
||||
}
|
||||
}
|
||||
if err := addAdvInventoryItem(userID, key.item); err != nil {
|
||||
slog.Error("secret room: grant key", "user", userID, "key", key.item.Name, "err", err)
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("🗝 **%s** — %s.", key.item.Name, key.unlockHint)
|
||||
}
|
||||
|
||||
// secretRoomDiscovery is the bespoke in-world discovery line for a secret room,
|
||||
// keyed by node ID. Not TwinBee's voice — the same in-world register the journal
|
||||
// pages use. Nodes without an entry fall back to a generic line built from the
|
||||
// node label.
|
||||
var secretRoomDiscovery = map[string]string{
|
||||
"crypt_valdris.secret_chamber": "🔍 **A sealed chamber.** The other grave-robbers pried at the wrong wall. This one gives — Valdris kept something back even from his own tomb.",
|
||||
"forest_shadows.sapling_shrine": "🔍 **The Sapling Shrine.** A ring of young antlers grown from the dirt, and something votive left at the centre — an offering the King's shell never came back to claim.",
|
||||
"sunken_temple.coral_reliquary": "🔍 **The Coral Reliquary.** Behind a lattice of living coral, a niche the flood sealed rather than drowned. What the temple hid, it hid well.",
|
||||
"manor_blackspire.hidden_oratory": "🔍 **The Hidden Oratory.** A chapel with no door in the floor plan, kept for a tenant the deeds refuse to name.",
|
||||
"manor_blackspire.sealed_reliquary": "🔍 **The Sealed Reliquary.** The drowned god's sigil bites into the lock and turns. Blackspire kept a piece of the temple's secret behind a door only the temple could open.",
|
||||
"underforge.forge_vault": "🔍 **The Forge Vault.** A strongroom off the unbanked heat, its ledger of commissions all addressed elsewhere and long overdue.",
|
||||
"underdark.lost_reliquary": "🔍 **A lost reliquary.** A shrine the deep roads swallowed whole, still lit, still waiting on a procession that stopped coming a long age ago.",
|
||||
"underdark.sealed_forge_vault": "🔍 **The Sealed Vault.** The Underforge's brand fits the seam, and the deep-road door gives up what the forge sent ahead — payment on the old account.",
|
||||
"feywild_crossing.illusion_garden": "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
|
||||
"dragons_lair.hoard_pillar": "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
|
||||
"abyss_portal.reality_seam": "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",
|
||||
}
|
||||
|
||||
// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
|
||||
// falling back to a label-driven generic when the node has no bespoke entry.
|
||||
func secretRoomDiscoveryLine(node ZoneNode) string {
|
||||
if line, ok := secretRoomDiscovery[node.NodeID]; ok {
|
||||
return line
|
||||
}
|
||||
label := node.Label
|
||||
if label == "" {
|
||||
label = "a hidden room"
|
||||
}
|
||||
return fmt.Sprintf("🔍 **%s** — a room the others walked past. Something was left here for whoever looked closer.", label)
|
||||
}
|
||||
|
||||
// bossEpilogues ties each zone boss's death to the Hollow King arc: a 2-3
|
||||
// sentence capstone appended to the boss-down moment. Forest of Shadows is the
|
||||
// King himself — but what falls there is a shell he shed, which is why the arc
|
||||
// (and the finale) outlives it. In-world narration, not TwinBee's voice.
|
||||
var bossEpilogues = map[ZoneID]string{
|
||||
ZoneGoblinWarrens: "Grol dies clutching a coin no goblin minted — a king's face worn smooth by handling. Whatever paid the warren to give up its deep tunnels, it paid in a currency older than these hills.",
|
||||
ZoneCryptValdris: "Valdris tried to cheat the grave and managed only to furnish it. In his last rattle he says a name that isn't his — _hollow, hollow_ — as if warning you of a colleague who did it better.",
|
||||
ZoneForestShadows: "The Hollow King falls without weight, a coat slipped from its peg — and the woods do not go quiet. What you felled here was a thing he shed, not the thing he is. Somewhere, the account he owes goes on accruing.",
|
||||
ZoneSunkenTemple: "The Aboleth's dream breaks and the drowned bells still at last. In the silence you understand what they were counting toward — and that the count did not begin with this temple, and does not end with it.",
|
||||
ZoneManorBlackspire: "Aldric was hollowed the same way, by the same hand, and made a poor imitation: a lord kept past his death to hold a house for a guest who never came. He thanks you. It is the first thing he has meant in a century.",
|
||||
ZoneUnderforge: "Thyrak's fires gutter out, and the half-made things on the anvils cool into what they were always going to be — regalia, and soldiers, and a crown with no head to fit. The forge was filling an order placed a long time ago.",
|
||||
ZoneUnderdark: "Ilvaras ruled the throat that swallows everything downward, toward the door at the bottom of the world. She dies certain she served a queen. She served a direction, and the direction has a name it never told her.",
|
||||
ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.",
|
||||
ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.",
|
||||
ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.",
|
||||
}
|
||||
|
||||
// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for
|
||||
// zones with none (and for the synthetic arena, which has no ZoneID entry).
|
||||
func bossEpilogueLine(zoneID ZoneID) string {
|
||||
return bossEpilogues[zoneID]
|
||||
}
|
||||
|
||||
// writeBossEpilogue appends a zone's campaign capstone (if any) to a
|
||||
// victory narration. Shared by every boss-down render path — the manual
|
||||
// turn-based finishes (finishCombatSession, finishPartyWin) and the compact
|
||||
// autopilot boss resolve — so the D1b epilogue fires no matter how the boss
|
||||
// was cleared. Caller gates on "this was a boss, not an elite".
|
||||
func writeBossEpilogue(b *strings.Builder, zoneID ZoneID) {
|
||||
if ep := bossEpilogueLine(zoneID); ep != "" {
|
||||
b.WriteString("\n" + ep + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// twinBeeJournalReactions are TwinBee's morning/digest reactions to pages found
|
||||
// during the day — first-person, implicit subject, he/him, one line, curious,
|
||||
// never expository (feedback_twinbee_voice, feedback_twinbee_is_male). Picked
|
||||
// deterministically so a re-rendered digest reads the same.
|
||||
var twinBeeJournalReactions = []string{
|
||||
"📖 Found a torn page in your kit tonight — been reading it by the fire while you sleep. This king of theirs was not a well man.",
|
||||
"📖 Another page. Keep turning them up and I keep piecing him together, and I do not much like the shape.",
|
||||
"📖 Read the new page twice. Whoever wrote it was frightened of something patient. I think we are walking toward it.",
|
||||
"📖 Slipped the day's page into the others. The story's filling in at the edges, and none of the edges are kind.",
|
||||
}
|
||||
|
||||
// twinBeeJournalReaction picks one reaction line deterministically from the day
|
||||
// and the number of pages found, so the digest is stable across re-renders.
|
||||
func twinBeeJournalReaction(day, pagesToday int) string {
|
||||
if len(twinBeeJournalReactions) == 0 || pagesToday <= 0 {
|
||||
return ""
|
||||
}
|
||||
idx := (day + pagesToday) % len(twinBeeJournalReactions)
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
return twinBeeJournalReactions[idx]
|
||||
}
|
||||
|
||||
// handleJournalCmd renders the player's collected campaign pages.
|
||||
func (p *AdventurePlugin) handleJournalCmd(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, renderJournal(char.JournalPages))
|
||||
}
|
||||
|
||||
// renderJournal builds the `!adventure journal` view: discovered pages in story
|
||||
// order, with runs of missing pages collapsed to a single "…".
|
||||
func renderJournal(mask int64) string {
|
||||
found := journalPageCount(mask)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("📖 **The Hollow King** — Journal (%d / %d pages)\n",
|
||||
found, journalTotalPages))
|
||||
|
||||
if found == 0 {
|
||||
b.WriteString("\nYou carry no pages yet. They surface where the realm's fragments gather — in the hands of elites, and behind doors most adventurers walk past.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
gapOpen := false
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if journalPageFound(mask, i) {
|
||||
gapOpen = false
|
||||
jp := journalPages[i-1]
|
||||
b.WriteString(fmt.Sprintf("\n**%s. %s**\n%s\n", romanNumeral(i), jp.Title, jp.Text))
|
||||
} else if !gapOpen {
|
||||
gapOpen = true
|
||||
b.WriteString("\n…\n")
|
||||
}
|
||||
}
|
||||
|
||||
if journalComplete(mask) {
|
||||
b.WriteString("\nThe ledger is whole. Clear both Tier-5 zones and you can follow him to the end — `!expedition start epilogue`.")
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("\n_%d pages still scattered._", journalTotalPages-found))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// romanNumeral renders 1..24 as upper-case Roman numerals for the page headers.
|
||||
// The campaign never exceeds a couple dozen pages, so a small table beats a
|
||||
// general algorithm here.
|
||||
func romanNumeral(n int) string {
|
||||
if n < 1 || n > len(romanNumerals) {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
return romanNumerals[n-1]
|
||||
}
|
||||
|
||||
// ── D1c: the finale ─────────────────────────────────────────────────────────
|
||||
|
||||
// finaleTitle is the unique role awarded for the first finale clear.
|
||||
const finaleTitle = "Kingsbane"
|
||||
|
||||
const epilogueEntryFlavor = "You bring the whole ledger to the Empty Throne. The shape kept warm against his return stirs, and stands, and is finally, fully here."
|
||||
|
||||
// hollowKingFinaleMonster is the finale stat block: Belaxath's block — the abyss
|
||||
// boss whose gate "was meant to let one thing come home" — re-dressed as the
|
||||
// King returned in full, with HP bumped for a capstone. A variant stat block
|
||||
// with no new mechanics, per the plan. Built on the fly (not registered in
|
||||
// dndBestiary) because runZoneCombat takes the template directly, exactly as the
|
||||
// arena bosses do.
|
||||
func hollowKingFinaleMonster() DnDMonsterTemplate {
|
||||
m := dndBestiary["boss_belaxath"]
|
||||
m.ID = "boss_hollow_king_finale"
|
||||
m.Name = "The Hollow King, Unhoused"
|
||||
m.HP = int(float64(m.HP) * 1.25)
|
||||
m.Notes = "Finale encounter — the Hollow King returned in full through the abyss gate. Variant of the Balor stat block; no new mechanics."
|
||||
return m
|
||||
}
|
||||
|
||||
// epilogueUnlocked gates the finale on the whole ledger plus both Tier-5 clears.
|
||||
func (p *AdventurePlugin) epilogueUnlocked(char *AdventureCharacter) (bool, string) {
|
||||
if !journalComplete(char.JournalPages) {
|
||||
return false, fmt.Sprintf(
|
||||
"The trail runs cold. You've recovered **%d of %d** journal pages — find them all before you can follow him to the end. (`!adventure journal`)",
|
||||
journalPageCount(char.JournalPages), journalTotalPages)
|
||||
}
|
||||
if !clearedEveryZoneOfTier(db.Get(), char.UserID, ZoneTier(5)) {
|
||||
return false, "You hold the whole ledger, but not the standing to use it. Clear **both** Tier-5 zones — the Dragon's Lair and the Abyss Portal — then come back to the throne."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// handleEpilogueEncounter runs the finale: a single auto-resolved boss fight
|
||||
// against the Hollow King's true form, reached via `!expedition start epilogue`.
|
||||
// Reward drops only on the first clear (reward-once); later clears are a
|
||||
// flavour-only rematch. A loss costs a death, as any boss fight does.
|
||||
func (p *AdventurePlugin) handleEpilogueEncounter(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` first.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're in no shape to face him. Mend at `!hospital` first.")
|
||||
}
|
||||
if ok, why := p.epilogueUnlocked(char); !ok {
|
||||
return p.SendDM(ctx.Sender, why)
|
||||
}
|
||||
// Busy guards — the finale is a synchronous auto-resolved fight, so refuse if
|
||||
// anything else is in flight and could collide on HP or run state.
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
return p.SendDM(ctx.Sender, "Finish your expedition before you walk to the Empty Throne.")
|
||||
}
|
||||
if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
|
||||
return p.SendDM(ctx.Sender, "Finish your current zone run first.")
|
||||
}
|
||||
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
|
||||
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it first.")
|
||||
}
|
||||
|
||||
monster := hollowKingFinaleMonster()
|
||||
displayName, _ := loadDisplayName(ctx.Sender)
|
||||
if displayName == "" {
|
||||
displayName = "You"
|
||||
}
|
||||
|
||||
preHP, _ := dndHPSnapshot(ctx.Sender)
|
||||
result, err := p.runZoneCombat(ctx.Sender, monster, 5, bossCombatPhases, 50)
|
||||
if err != nil {
|
||||
slog.Error("epilogue: combat failed", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong at the throne. Try again in a moment.")
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(ctx.Sender)
|
||||
nat20s, nat1s := countNat20sAnd1s(result)
|
||||
|
||||
intro := fmt.Sprintf("👑 **The Empty Throne — %s** (HP %d, AC %d)\n_%s_",
|
||||
monster.Name, monster.HP, monster.AC, epilogueEntryFlavor)
|
||||
phases := RenderCombatLog(result, displayName, monster.Name)
|
||||
outcome := renderBossOutcome(BossOutcomeInputs{
|
||||
// Synthetic ZoneArena keeps twinBeeLine's deterministic pickers off any
|
||||
// real zone's pool, exactly as the arena does.
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "epilogue-" + string(ctx.Sender),
|
||||
RoomIdx: 0,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: preHP,
|
||||
PostHP: postHP,
|
||||
MaxHP: maxHP,
|
||||
PhaseTwoAt: 0.40,
|
||||
Nat20s: nat20s,
|
||||
Nat1s: nat1s,
|
||||
DefeatHeadline: "💀 The Hollow King folds you into the quiet he keeps. The account goes unpaid a while longer.",
|
||||
VictoryHeadline: fmt.Sprintf("🏆 **The Hollow King falls — and this time stays down.** You finished at **%d/%d HP**.", postHP, maxHP),
|
||||
})
|
||||
|
||||
var tail string
|
||||
if !result.PlayerWon {
|
||||
markAdventureDead(ctx.Sender, "adventure", "the Empty Throne")
|
||||
} else {
|
||||
tail = p.finishEpilogueWin(ctx.Sender, char.EpilogueCleared)
|
||||
}
|
||||
|
||||
_ = p.sendZoneCombatMessages(ctx.Sender, append([]string{intro}, phases...), joinLootLines(outcome, tail))
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishEpilogueWin grants the reward on a first clear and returns the reward
|
||||
// narration; a repeat clear is flavour only. The character is reloaded before
|
||||
// the title write so runZoneCombat's post-fight HP persist isn't clobbered.
|
||||
func (p *AdventurePlugin) finishEpilogueWin(userID id.UserID, alreadyCleared bool) string {
|
||||
if alreadyCleared {
|
||||
return "_The throne stays empty. You came to be sure. You are sure._"
|
||||
}
|
||||
|
||||
// Latch the once-only flag BEFORE handing out the Legendary + title, so a
|
||||
// failed write (or a crash) can never leave the reward repeatable. If the
|
||||
// latch fails, skip the grant entirely — the player re-enters uncleared and
|
||||
// can try again, rather than pocketing a second Legendary on the retry.
|
||||
if err := markEpilogueClearedDB(userID); err != nil {
|
||||
slog.Error("epilogue: mark cleared failed", "user", userID, "err", err)
|
||||
return "_The account won't quite close — the ledger jams. Try `!expedition start epilogue` again._"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("🎖️ **The account is closed.** You are named **" + finaleTitle + "** — the one who unhoused the Hollow King.\n")
|
||||
if mi, ok := pickMagicItemForRarity(RarityLegendary, nil); ok {
|
||||
if line := p.dropMagicItemLoot(userID, mi, LootTierLegendary); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
} else {
|
||||
slog.Error("epilogue: no legendary in registry", "user", userID)
|
||||
}
|
||||
|
||||
if fresh, err := loadAdvCharacter(userID); err == nil && fresh != nil {
|
||||
if fresh.Title != finaleTitle {
|
||||
fresh.Title = finaleTitle
|
||||
if err := saveAdvCharacter(fresh); err != nil {
|
||||
slog.Error("epilogue: title save failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gr := gamesRoom(); gr != "" {
|
||||
if dn, _ := loadDisplayName(userID); dn != "" {
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"👑 **%s** carried the whole ledger to the Empty Throne and closed the account. The Hollow King is ended. **%s.**",
|
||||
dn, finaleTitle))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var romanNumerals = []string{
|
||||
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X",
|
||||
"XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX",
|
||||
"XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII", "XXVIII", "XXIX", "XXX",
|
||||
}
|
||||
300
internal/plugin/adventure_flavor_campaign_test.go
Normal file
300
internal/plugin/adventure_flavor_campaign_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestJournalGrant_DBRoundTripIsAtomicOR(t *testing.T) {
|
||||
townTestDB(t)
|
||||
u := id.UserID("@page:test.invalid")
|
||||
|
||||
// No row yet → zero pages.
|
||||
if mask, err := loadJournalPages(u); err != nil || mask != 0 {
|
||||
t.Fatalf("fresh player: mask=%d err=%v, want 0/nil", mask, err)
|
||||
}
|
||||
|
||||
// First grant creates the row (ON CONFLICT INSERT path).
|
||||
if err := grantJournalPageDB(u, 5); err != nil {
|
||||
t.Fatalf("grant page 5: %v", err)
|
||||
}
|
||||
// Second grant ORs into the existing row without clobbering the first.
|
||||
if err := grantJournalPageDB(u, 2); err != nil {
|
||||
t.Fatalf("grant page 2: %v", err)
|
||||
}
|
||||
// Re-granting a found page is idempotent.
|
||||
if err := grantJournalPageDB(u, 5); err != nil {
|
||||
t.Fatalf("re-grant page 5: %v", err)
|
||||
}
|
||||
|
||||
mask, err := loadJournalPages(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !journalPageFound(mask, 2) || !journalPageFound(mask, 5) {
|
||||
t.Fatalf("pages 2 and 5 should be found, mask=%b", mask)
|
||||
}
|
||||
if journalPageCount(mask) != 2 {
|
||||
t.Fatalf("count=%d, want 2 (mask=%b)", journalPageCount(mask), mask)
|
||||
}
|
||||
|
||||
// Overlay populates the character field the viewer reads.
|
||||
var c AdventureCharacter
|
||||
c.UserID = u
|
||||
applyPlayerMetaOverlay(&c)
|
||||
if c.JournalPages != mask {
|
||||
t.Fatalf("overlay JournalPages=%b, want %b", c.JournalPages, mask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalCatalog_WellFormed(t *testing.T) {
|
||||
if journalTotalPages != len(journalPages) {
|
||||
t.Fatalf("journalTotalPages %d != len(journalPages) %d", journalTotalPages, len(journalPages))
|
||||
}
|
||||
if journalTotalPages < 1 || journalTotalPages > 63 {
|
||||
t.Fatalf("journal must fit an int64 bitmask (1..63); got %d", journalTotalPages)
|
||||
}
|
||||
if journalTotalPages > len(romanNumerals) {
|
||||
t.Fatalf("more pages (%d) than roman numerals (%d)", journalTotalPages, len(romanNumerals))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, jp := range journalPages {
|
||||
if strings.TrimSpace(jp.Title) == "" {
|
||||
t.Errorf("page %d has empty title", i+1)
|
||||
}
|
||||
if strings.TrimSpace(jp.Text) == "" {
|
||||
t.Errorf("page %d (%q) has empty text", i+1, jp.Title)
|
||||
}
|
||||
if seen[jp.Title] {
|
||||
t.Errorf("duplicate page title %q", jp.Title)
|
||||
}
|
||||
seen[jp.Title] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalBitmask_GrantQueryCount(t *testing.T) {
|
||||
var mask int64
|
||||
if journalPageCount(mask) != 0 {
|
||||
t.Fatalf("empty mask should count 0")
|
||||
}
|
||||
if journalPageFound(mask, 1) {
|
||||
t.Fatalf("page 1 should be unfound on empty mask")
|
||||
}
|
||||
mask = setJournalPageBit(mask, 3)
|
||||
mask = setJournalPageBit(mask, 1)
|
||||
if !journalPageFound(mask, 3) || !journalPageFound(mask, 1) {
|
||||
t.Fatalf("pages 1 and 3 should be found")
|
||||
}
|
||||
if journalPageFound(mask, 2) {
|
||||
t.Fatalf("page 2 should still be unfound")
|
||||
}
|
||||
if got := journalPageCount(mask); got != 2 {
|
||||
t.Fatalf("count = %d, want 2", got)
|
||||
}
|
||||
// Re-setting a found page is idempotent.
|
||||
if again := setJournalPageBit(mask, 3); again != mask {
|
||||
t.Fatalf("re-granting page 3 changed the mask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickUnfoundJournalPage_ExhaustsToZero(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
var mask int64
|
||||
found := map[int]bool{}
|
||||
for i := 0; i < journalTotalPages; i++ {
|
||||
page := pickUnfoundJournalPage(mask, rng)
|
||||
if page < 1 || page > journalTotalPages {
|
||||
t.Fatalf("pick %d out of range", page)
|
||||
}
|
||||
if found[page] {
|
||||
t.Fatalf("pick returned already-found page %d", page)
|
||||
}
|
||||
found[page] = true
|
||||
mask = setJournalPageBit(mask, page)
|
||||
}
|
||||
if !journalComplete(mask) {
|
||||
t.Fatalf("mask should be complete after %d picks", journalTotalPages)
|
||||
}
|
||||
if page := pickUnfoundJournalPage(mask, rng); page != 0 {
|
||||
t.Fatalf("pick on complete mask = %d, want 0", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderJournal_EmptyPartialFull(t *testing.T) {
|
||||
// Empty.
|
||||
empty := renderJournal(0)
|
||||
if !strings.Contains(empty, "0 / ") {
|
||||
t.Errorf("empty journal should show 0 found:\n%s", empty)
|
||||
}
|
||||
if strings.Contains(empty, "…") {
|
||||
t.Errorf("empty journal should not render a gap marker:\n%s", empty)
|
||||
}
|
||||
|
||||
// Partial with a gap: pages 1 and 3 found, 2 missing → exactly one "…".
|
||||
var partial int64
|
||||
partial = setJournalPageBit(partial, 1)
|
||||
partial = setJournalPageBit(partial, 3)
|
||||
pv := renderJournal(partial)
|
||||
if !strings.Contains(pv, journalPages[0].Title) || !strings.Contains(pv, journalPages[2].Title) {
|
||||
t.Errorf("partial journal missing a found page title:\n%s", pv)
|
||||
}
|
||||
if strings.Contains(pv, journalPages[1].Title) {
|
||||
t.Errorf("partial journal leaked an unfound page's text:\n%s", pv)
|
||||
}
|
||||
if !strings.Contains(pv, "…") {
|
||||
t.Errorf("partial journal with a gap should render '…':\n%s", pv)
|
||||
}
|
||||
|
||||
// Full: every title present, no gap, completion line.
|
||||
var full int64
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
full = setJournalPageBit(full, i)
|
||||
}
|
||||
fv := renderJournal(full)
|
||||
if strings.Contains(fv, "…") {
|
||||
t.Errorf("complete journal should have no gaps:\n%s", fv)
|
||||
}
|
||||
for _, jp := range journalPages {
|
||||
if !strings.Contains(fv, jp.Title) {
|
||||
t.Errorf("complete journal missing page %q", jp.Title)
|
||||
}
|
||||
}
|
||||
if !journalComplete(full) {
|
||||
t.Fatalf("full mask should be complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalDropChance_Sane(t *testing.T) {
|
||||
if journalPageDropChance <= 0 || journalPageDropChance >= 1 {
|
||||
t.Fatalf("drop chance %v out of (0,1)", journalPageDropChance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBossEpilogues_EveryRegisteredZoneHasOne(t *testing.T) {
|
||||
for _, z := range allZones() {
|
||||
if strings.TrimSpace(bossEpilogueLine(z.ID)) == "" {
|
||||
t.Errorf("zone %s (%s) has no boss epilogue", z.ID, z.Display)
|
||||
}
|
||||
}
|
||||
// The synthetic arena is not a campaign zone — it must have none.
|
||||
if bossEpilogueLine(ZoneArena) != "" {
|
||||
t.Errorf("arena should have no boss epilogue")
|
||||
}
|
||||
if bossEpilogueLine(ZoneID("nonexistent")) != "" {
|
||||
t.Errorf("unknown zone should have no epilogue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHollowKingFinaleMonster_ClonesBelaxathWithBump(t *testing.T) {
|
||||
base := dndBestiary["boss_belaxath"]
|
||||
if base.ID == "" {
|
||||
t.Fatal("boss_belaxath missing from bestiary — finale clones it")
|
||||
}
|
||||
m := hollowKingFinaleMonster()
|
||||
if m.ID != "boss_hollow_king_finale" {
|
||||
t.Errorf("finale ID = %q", m.ID)
|
||||
}
|
||||
if m.Name == base.Name || m.Name == "" {
|
||||
t.Errorf("finale name should differ from Belaxath: %q", m.Name)
|
||||
}
|
||||
if m.HP <= base.HP {
|
||||
t.Errorf("finale HP %d should exceed base %d (capstone bump)", m.HP, base.HP)
|
||||
}
|
||||
// No new mechanics: the ability and defensive profile carry over unchanged.
|
||||
if m.Ability != base.Ability {
|
||||
t.Errorf("finale ability pointer changed — should reuse Belaxath's, no new mechanics")
|
||||
}
|
||||
if m.AC != base.AC || m.CR != base.CR {
|
||||
t.Errorf("finale AC/CR drifted from base: AC %d/%d CR %v/%v", m.AC, base.AC, m.CR, base.CR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpilogueRewardOnce_FlagRoundTrip(t *testing.T) {
|
||||
townTestDB(t)
|
||||
u := id.UserID("@finale:test.invalid")
|
||||
|
||||
if got, err := loadEpilogueCleared(u); err != nil || got {
|
||||
t.Fatalf("fresh player: cleared=%v err=%v, want false/nil", got, err)
|
||||
}
|
||||
if err := markEpilogueClearedDB(u); err != nil {
|
||||
t.Fatalf("mark cleared: %v", err)
|
||||
}
|
||||
if got, err := loadEpilogueCleared(u); err != nil || !got {
|
||||
t.Fatalf("after mark: cleared=%v err=%v, want true/nil", got, err)
|
||||
}
|
||||
// Idempotent.
|
||||
if err := markEpilogueClearedDB(u); err != nil {
|
||||
t.Fatalf("re-mark: %v", err)
|
||||
}
|
||||
|
||||
var c AdventureCharacter
|
||||
c.UserID = u
|
||||
applyPlayerMetaOverlay(&c)
|
||||
if !c.EpilogueCleared {
|
||||
t.Fatalf("overlay did not carry EpilogueCleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpilogueUnlocked_Gates(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
// Incomplete journal → refused, page-count message.
|
||||
partial := &AdventureCharacter{UserID: id.UserID("@u1:test.invalid")}
|
||||
partial.JournalPages = setJournalPageBit(0, 1)
|
||||
ok, why := p.epilogueUnlocked(partial)
|
||||
if ok {
|
||||
t.Fatalf("incomplete journal should not unlock")
|
||||
}
|
||||
if !strings.Contains(why, "journal pages") {
|
||||
t.Errorf("expected page-count refusal, got: %s", why)
|
||||
}
|
||||
|
||||
// Complete journal but no Tier-5 clears → refused, T5 message.
|
||||
full := &AdventureCharacter{UserID: id.UserID("@u2:test.invalid")}
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
full.JournalPages = setJournalPageBit(full.JournalPages, i)
|
||||
}
|
||||
ok, why = p.epilogueUnlocked(full)
|
||||
if ok {
|
||||
t.Fatalf("no T5 clears should not unlock")
|
||||
}
|
||||
if !strings.Contains(why, "Tier-5") {
|
||||
t.Errorf("expected Tier-5 refusal, got: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishEpilogueWin_RepeatIsFlavorOnly(t *testing.T) {
|
||||
// The already-cleared branch is pure flavour: no reward, no client calls.
|
||||
p := &AdventurePlugin{}
|
||||
repeat := p.finishEpilogueWin(id.UserID("@u:test.invalid"), true)
|
||||
if strings.Contains(repeat, finaleTitle) {
|
||||
t.Errorf("a repeat clear must not re-award the title: %s", repeat)
|
||||
}
|
||||
if strings.TrimSpace(repeat) == "" {
|
||||
t.Errorf("a repeat clear should still say something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwinBeeJournalReaction_DeterministicAndGuarded(t *testing.T) {
|
||||
if twinBeeJournalReaction(3, 0) != "" {
|
||||
t.Errorf("zero pages should produce no reaction")
|
||||
}
|
||||
first := twinBeeJournalReaction(3, 2)
|
||||
if first == "" {
|
||||
t.Fatalf("a found page should produce a reaction")
|
||||
}
|
||||
if again := twinBeeJournalReaction(3, 2); again != first {
|
||||
t.Errorf("reaction not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
// Voice guard: TwinBee never speaks of himself in the third person
|
||||
// (feedback_twinbee_voice). None of the pooled lines may contain "TwinBee".
|
||||
for _, line := range twinBeeJournalReactions {
|
||||
if strings.Contains(line, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee reference in reaction: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,31 @@ var mistyAcceptLines = []string{
|
||||
|
||||
const mistyDeclineLine = "She nods once and walks away. She doesn't look back."
|
||||
|
||||
// ── Misty — Arc Beats (D2) ─────────────────────────────────────────────────
|
||||
//
|
||||
// Three deepening dialogue beats keyed to MistyEncounterCount (5 / 15 / 30),
|
||||
// prepended to the encounter opening the one time the counter lands on a
|
||||
// threshold. Fiction only: nothing here connects a donation to a combat
|
||||
// outcome — the arena effects are a hidden discovery mechanic and stay hidden.
|
||||
var mistyArcBeats = map[int]string{
|
||||
5: "It's you again. I was hoping it would be. I know your face now -- you're one of the few who ever stops. " +
|
||||
"Most people look right through me. You never have.",
|
||||
|
||||
15: "Can I tell you something? I wasn't always out here asking strangers on the road. " +
|
||||
"I had a home once. A garden that got away from me every summer. Someone who used to wait up. " +
|
||||
"It's gone now, all of it. But you keep reminding me there are still kind people in the world. That's not nothing.",
|
||||
|
||||
30: "I don't think of you as a stranger anymore. Is it strange to say that? " +
|
||||
"I catch myself watching the road for you now -- not for the gold, though I won't pretend it doesn't help. " +
|
||||
"Just to see you're still standing. Whatever's out there, I hope it knows better than to take you from me too.",
|
||||
}
|
||||
|
||||
// mistyArcBeat returns the deepening dialogue for the exact encounter count at
|
||||
// which a Misty stage lands, or "" for every other encounter.
|
||||
func mistyArcBeat(encounterCount int) string {
|
||||
return mistyArcBeats[encounterCount]
|
||||
}
|
||||
|
||||
// ── Arina — Opening Lines ──────────────────────────────────────────────────
|
||||
|
||||
var arinaOpenings = []string{
|
||||
|
||||
@@ -40,6 +40,11 @@ var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excel
|
||||
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
|
||||
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
|
||||
|
||||
// robbieLeftConsumable is appended when Robbie leaves a little something on his
|
||||
// every-10th-visit (D2). Takes the item name.
|
||||
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
|
||||
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
|
||||
|
||||
// ── Room Announcements ───────────────────────────────────────────────────────
|
||||
|
||||
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
||||
|
||||
@@ -146,6 +146,18 @@ var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house
|
||||
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
|
||||
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
|
||||
|
||||
// thomPetTreatItem is the keepsake Thom leaves for the pet on the final
|
||||
// mortgage payoff. Inert (Type "card" like the medical-debt card): not a
|
||||
// consumable, not gear, and Robbie's sweep skips it.
|
||||
func thomPetTreatItem() AdvItem {
|
||||
return AdvItem{
|
||||
Name: "Krooke's Finest Pet Treat",
|
||||
Type: "card",
|
||||
Tier: 0,
|
||||
Value: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// thomGreeting returns an appropriate greeting based on pet state.
|
||||
func thomGreeting(char *AdventureCharacter) string {
|
||||
if char.HasPet() && char.PetLevel >= 10 {
|
||||
@@ -553,11 +565,22 @@ func (p *AdventurePlugin) processMortgagePayments() {
|
||||
freshChar.HouseLoanBalance = 0
|
||||
freshChar.HouseMissedPayments = 0
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
// The last house: no next tier means Thom is done with this
|
||||
// player. A letter, and a treat in the bag if they keep a pet.
|
||||
finalPayoff := houseNextTier(freshChar.HouseTier) == nil
|
||||
if finalPayoff && freshChar.HasPet() {
|
||||
_ = addAdvInventoryItem(freshChar.UserID, thomPetTreatItem())
|
||||
}
|
||||
userMu.Unlock()
|
||||
// Paid off!
|
||||
if freshChar.HasPet() {
|
||||
switch {
|
||||
case finalPayoff && freshChar.HasPet():
|
||||
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", freshChar.PetName)
|
||||
_ = p.SendDM(freshChar.UserID, "🏠 "+letter)
|
||||
case finalPayoff:
|
||||
_ = p.SendDM(freshChar.UserID, "🏠 "+thomFinalLetterNoPet)
|
||||
case freshChar.HasPet():
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
|
||||
} else {
|
||||
default:
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
continue
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
91
internal/plugin/adventure_npc_arcs_test.go
Normal file
91
internal/plugin/adventure_npc_arcs_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D2 NPC arcs — pure-helper coverage. None of these touch combat, so the
|
||||
// characterization golden is unaffected; these just pin the arc thresholds,
|
||||
// gift tiers, and the keepsake shape.
|
||||
|
||||
func TestMistyArcBeat_FiresOnlyAtThresholds(t *testing.T) {
|
||||
want := map[int]bool{5: true, 15: true, 30: true}
|
||||
for count := 0; count <= 40; count++ {
|
||||
got := mistyArcBeat(count)
|
||||
if want[count] {
|
||||
if got == "" {
|
||||
t.Errorf("encounter %d: expected an arc beat, got none", count)
|
||||
}
|
||||
} else if got != "" {
|
||||
t.Errorf("encounter %d: expected no arc beat, got %q", count, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyArcBeats_DoNotLeakBuffMechanics(t *testing.T) {
|
||||
// The arena effects are a hidden discovery mechanic; arc copy must never
|
||||
// tie a donation to a combat outcome.
|
||||
banned := []string{"arena", "damage", "buff", "combat", "attack", "heal", "bonus"}
|
||||
for count, beat := range mistyArcBeats {
|
||||
lower := strings.ToLower(beat)
|
||||
for _, b := range banned {
|
||||
if strings.Contains(lower, b) {
|
||||
t.Errorf("beat %d leaks mechanic word %q: %s", count, b, beat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobbieGiftTier_MatchesArenaBands(t *testing.T) {
|
||||
cases := []struct {
|
||||
level int
|
||||
tier int
|
||||
}{
|
||||
{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3},
|
||||
{13, 4}, {17, 4}, {18, 5}, {20, 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := robbieGiftTier(c.level); got != c.tier {
|
||||
t.Errorf("robbieGiftTier(%d) = %d, want %d", c.level, got, c.tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobbieGiftCadence(t *testing.T) {
|
||||
// The gift lands on every 10th visit and no other.
|
||||
for visit := 1; visit <= 35; visit++ {
|
||||
gives := visit%robbieGiftEveryNVisits == 0
|
||||
if gives != (visit == 10 || visit == 20 || visit == 30) {
|
||||
t.Errorf("visit %d: unexpected gift cadence", visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomPetTreat_IsRobbieSafeKeepsake(t *testing.T) {
|
||||
treat := thomPetTreatItem()
|
||||
if treat.Name == "" {
|
||||
t.Fatal("treat has no name")
|
||||
}
|
||||
// Robbie's sweep skips "card" items, so the keepsake survives him.
|
||||
if treat.Type != "card" {
|
||||
t.Errorf("treat type = %q, want card so Robbie's sweep skips it", treat.Type)
|
||||
}
|
||||
if treat.Value != 0 {
|
||||
t.Errorf("treat value = %d, want 0 (inert keepsake)", treat.Value)
|
||||
}
|
||||
// Confirm the sweep filter actually excludes it.
|
||||
if got := robbieQualifyingItems([]AdvItem{treat}, nil); len(got) != 0 {
|
||||
t.Errorf("Robbie would take the treat: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomFinalLetterPet_SubstitutesPetName(t *testing.T) {
|
||||
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", "Biscuit")
|
||||
if strings.Contains(letter, "{pet}") {
|
||||
t.Error("unsubstituted {pet} placeholder remains")
|
||||
}
|
||||
if !strings.Contains(letter, "Biscuit") {
|
||||
t.Error("pet name not substituted into the letter")
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,15 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't consume the encounter — or its one-shot arc beat — if the player is
|
||||
// already mid-interaction (shop, treasure, another NPC). Bail before
|
||||
// touching MistyEncounterCount so a contended slot defers the whole
|
||||
// encounter to a later fire instead of durably advancing the counter past
|
||||
// a 5/15/30 threshold and losing that beat forever.
|
||||
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
var opening, prompt string
|
||||
@@ -141,6 +150,12 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
// Pool union: legacy mistyOpenings + D&D MistyGreeting flavor.
|
||||
mistyPool := dndMistyGreetingPool()
|
||||
opening = mistyPool[rand.IntN(len(mistyPool))]
|
||||
// D2 arc: a deepening beat lands the one time the counter hits a
|
||||
// threshold (5/15/30). MistyEncounterCount only ever increments by one
|
||||
// above, so each beat is delivered exactly once.
|
||||
if beat := mistyArcBeat(char.MistyEncounterCount); beat != "" {
|
||||
opening = beat + "\n\n" + opening
|
||||
}
|
||||
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
|
||||
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
|
||||
case "arina":
|
||||
@@ -159,11 +174,6 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
slog.Error("player_meta: npc last_seen dual-write failed", "user", userID, "npc", npc, "err", err)
|
||||
}
|
||||
|
||||
// Don't overwrite an existing pending interaction (shop, treasure, etc.)
|
||||
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending interaction — NPC encounters stay valid until end of UTC day
|
||||
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
|
||||
101
internal/plugin/adventure_omen.go
Normal file
101
internal/plugin/adventure_omen.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/B3 — the Omen: one rotating world modifier per ISO week
|
||||
// (gogobee_engagement_plan.md §B3).
|
||||
//
|
||||
// The active omen is a pure function of the ISO (year, week): omenTable indexed
|
||||
// by (year*53 + week) % len, so it advances by exactly one entry each week and
|
||||
// needs no schema, no ticker state, and no persistence. Every seam reads
|
||||
// activeOmen() the same way isHolidayToday() is read, and a zero-valued effect
|
||||
// field means "this omen doesn't touch that seam."
|
||||
//
|
||||
// Launch-set rule (§B3): every omen is a buff-with-texture on a NON-combat
|
||||
// lever — harvest, supplies, expedition mood/threat, arena payout, ingredient
|
||||
// drops. Nothing touches SimulateCombat or the turn engine: the omen is keyed
|
||||
// on the real clock, so a combat-affecting omen would make the combat golden
|
||||
// and the balance corpus week-dependent. The plan's "elites +2 ATK" mutator is
|
||||
// deliberately omitted for exactly that reason.
|
||||
|
||||
type omen struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing name, e.g. "Bountiful Harvest"
|
||||
TwinBee string // first-person announce blurb (TwinBee voice)
|
||||
|
||||
// Effect fields — zero == no effect at that seam.
|
||||
HarvestYieldBonus int // + units per harvest grant
|
||||
SupplyFreebiePacks int // + complimentary standard packs at outfitting
|
||||
StartMoodBonus int // + starting DM mood on a new expedition
|
||||
ArenaPayoutMult float64 // >1 scales arena net earnings
|
||||
ConsumableChanceMult float64 // >1 scales the per-win ingredient drop chance
|
||||
ThreatDriftReduce int // subtract from the daily threat *rise* (floored at hold-steady)
|
||||
}
|
||||
|
||||
// simOmenDisabled neutralizes the weekly Omen for the balance sim, so a corpus
|
||||
// sweep's results never depend on which wall-clock week it was run in. Set true
|
||||
// by NewSimRunner (mirrors simAutoArmEnabled). Every seam reads activeOmen(),
|
||||
// which returns the no-effect omen while this is set.
|
||||
var simOmenDisabled bool
|
||||
|
||||
// omenTable is the weekly rotation. Keep it a set of distinct non-combat levers;
|
||||
// order is the rotation order. Adding an entry reshuffles the schedule but never
|
||||
// breaks determinism (still a pure function of the week).
|
||||
var omenTable = []omen{
|
||||
{
|
||||
Key: "bountiful_harvest", Name: "Bountiful Harvest",
|
||||
TwinBee: "The land's feeling generous this week — every gather I make comes up with a little extra in hand.",
|
||||
HarvestYieldBonus: 1,
|
||||
},
|
||||
{
|
||||
Key: "quartermasters_blessing", Name: "Quartermaster's Blessing",
|
||||
TwinBee: "Somebody left the storerooms unlocked. Outfitting an expedition this week? There's a free pack in it, and I'm setting out in good spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
{
|
||||
Key: "golden_purse", Name: "Golden Purse",
|
||||
TwinBee: "The arena crowd's flush this week — purses are paying out fat. Twenty percent over the odds, if you can win it.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
{
|
||||
Key: "overflowing_satchels", Name: "Overflowing Satchels",
|
||||
TwinBee: "Reagents are turning up everywhere I look — twice as often as usual. Good week to stock the crafting shelf.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
{
|
||||
Key: "still_waters", Name: "Still Waters",
|
||||
TwinBee: "It's quiet out there. Whatever's hunting us is slow to rouse this week — the daily dread holds steady instead of creeping up.",
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
}
|
||||
|
||||
// omenForWeek returns the omen for an ISO (year, week). Pure and total.
|
||||
func omenForWeek(year, week int) omen {
|
||||
idx := ((year*53)+week)%len(omenTable) + len(omenTable)
|
||||
return omenTable[idx%len(omenTable)]
|
||||
}
|
||||
|
||||
// activeOmen returns this week's omen (UTC ISO week), or a no-effect omen when
|
||||
// the balance sim has disabled it.
|
||||
func activeOmen() omen {
|
||||
if simOmenDisabled {
|
||||
return omen{Key: "none", Name: "None"}
|
||||
}
|
||||
// N7/E4 — a live season's themed omen overrides the weekly rotation. Kept
|
||||
// behind the simOmenDisabled guard above so the balance sim never sees it.
|
||||
if s, ok := activeSeason(); ok {
|
||||
return s.Omen
|
||||
}
|
||||
y, w := time.Now().UTC().ISOWeek()
|
||||
return omenForWeek(y, w)
|
||||
}
|
||||
|
||||
// omenMorningLine is the compact one-liner surfaced in the morning DM (the §B3
|
||||
// announce seam). Empty is never returned — an omen is always active — but the
|
||||
// caller may still choose when to show it.
|
||||
func omenMorningLine(o omen) string {
|
||||
return "🔮 **The Omen — " + o.Name + ".** _" + o.TwinBee + "_"
|
||||
}
|
||||
86
internal/plugin/adventure_omen_test.go
Normal file
86
internal/plugin/adventure_omen_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestOmenForWeek_Deterministic — the same ISO week always yields the same omen.
|
||||
func TestOmenForWeek_Deterministic(t *testing.T) {
|
||||
a := omenForWeek(2026, 28)
|
||||
b := omenForWeek(2026, 28)
|
||||
if a.Key != b.Key {
|
||||
t.Fatalf("omenForWeek not deterministic: %q vs %q", a.Key, b.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_AdvancesEachWeek — consecutive weeks step by exactly one table
|
||||
// entry, so the schedule rotates rather than sticking or skipping.
|
||||
func TestOmenForWeek_AdvancesEachWeek(t *testing.T) {
|
||||
n := len(omenTable)
|
||||
for w := 1; w <= n; w++ {
|
||||
cur := omenForWeek(2026, w)
|
||||
next := omenForWeek(2026, w+1)
|
||||
wantNext := omenTable[((2026*53)+w+1)%n]
|
||||
if next.Key != wantNext.Key {
|
||||
t.Errorf("week %d→%d: got %q, want %q", w, w+1, next.Key, wantNext.Key)
|
||||
}
|
||||
if cur.Key == next.Key {
|
||||
t.Errorf("week %d and %d produced the same omen %q (should advance)", w, w+1, cur.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_TotalOverYearBoundary — never panics across week/year edges,
|
||||
// including ISO week 53.
|
||||
func TestOmenForWeek_TotalOverYearBoundary(t *testing.T) {
|
||||
for y := 2020; y <= 2030; y++ {
|
||||
for w := 1; w <= 53; w++ {
|
||||
o := omenForWeek(y, w)
|
||||
if o.Key == "" {
|
||||
t.Fatalf("omenForWeek(%d,%d) returned zero omen", y, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenTable_NonCombatOnly — every omen carries at least one effect and the
|
||||
// struct has no combat lever, so the launch set can never move the golden or the
|
||||
// balance corpus. §B3.
|
||||
func TestOmenTable_NonCombatOnly(t *testing.T) {
|
||||
for _, o := range omenTable {
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("omen %q has no active effect", o.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("omen %q missing player-facing copy", o.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveOmen_SimDisabled — the balance sim neutralizes the omen so corpus
|
||||
// results are wall-clock-independent. §B3.
|
||||
func TestActiveOmen_SimDisabled(t *testing.T) {
|
||||
defer func() { simOmenDisabled = false }()
|
||||
simOmenDisabled = true
|
||||
o := activeOmen()
|
||||
if o.Key != "none" {
|
||||
t.Errorf("sim-disabled omen = %q, want none", o.Key)
|
||||
}
|
||||
if o.HarvestYieldBonus != 0 || o.SupplyFreebiePacks != 0 || o.StartMoodBonus != 0 ||
|
||||
o.ArenaPayoutMult != 0 || o.ConsumableChanceMult != 0 || o.ThreatDriftReduce != 0 {
|
||||
t.Errorf("sim-disabled omen must have no effect, got %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenKeysUnique — no duplicate keys (a dup would make the rotation land on
|
||||
// the same omen two of every len(omenTable) weeks).
|
||||
func TestOmenKeysUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range omenTable {
|
||||
if seen[o.Key] {
|
||||
t.Errorf("duplicate omen key %q", o.Key)
|
||||
}
|
||||
seen[o.Key] = true
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,20 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
|
||||
}
|
||||
|
||||
// N7/B3 the Omen — this week's world modifier, in TwinBee's voice. Rides the
|
||||
// existing morning DM (no net-new scheduled message); persistent one-liner so
|
||||
// a mid-week arrival still learns the active omen.
|
||||
sb.WriteString(omenMorningLine(activeOmen()))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// N7/E4 — a live season adds a "what's live this week" banner under the Omen
|
||||
// line (its themed omen already rode the line above). Same morning DM, no
|
||||
// net-new scheduled message.
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(seasonBannerLine(s))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Pick a morning greeting
|
||||
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
@@ -974,6 +988,7 @@ type AdvLeaderboardEntry struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
CurrentStreak int
|
||||
Renown int // N7/B2 — prestige level, cosmetic marker only
|
||||
}
|
||||
|
||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
@@ -987,16 +1002,21 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
Score int
|
||||
Levels string
|
||||
Streak int
|
||||
Renown int
|
||||
}
|
||||
var entries []entry
|
||||
for _, c := range chars {
|
||||
// Renown adds to the ranking score so a capped, prestigious player still
|
||||
// climbs — it's the only progression they have left past L20.
|
||||
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||
score += c.Renown * 10
|
||||
name, _ := loadDisplayName(c.UserID)
|
||||
entries = append(entries, entry{
|
||||
Name: name,
|
||||
Score: score,
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||
Streak: c.CurrentStreak,
|
||||
Renown: c.Renown,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1028,7 +1048,11 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
if e.Streak >= 7 {
|
||||
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
||||
renownBadge := ""
|
||||
if m := renownMarker(e.Renown); m != "" {
|
||||
renownBadge = " " + m
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s**%s — %s (score: %d%s)\n", prefix, e.Name, renownBadge, e.Levels, e.Score, streak))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
||||
249
internal/plugin/adventure_renown.go
Normal file
249
internal/plugin/adventure_renown.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// N7/B2 — Renown: prestige past the level cap (gogobee_engagement_plan.md §B2).
|
||||
//
|
||||
// A confirmed D&D character caps at L20 (dndMaxLevel). Before N7, grantDnDXP
|
||||
// silently dropped any XP earned past the cap. Renown reclaims that overflow as
|
||||
// a prestige track: every renownXPPerLevel of overflow is one Renown level.
|
||||
//
|
||||
// Storage is a single cumulative column, player_meta.renown_xp, written by an
|
||||
// atomic INSERT…ON CONFLICT … += (the journal_pages pattern) so there is no
|
||||
// read-modify-write race. renown_level is DERIVED (renownLevelFor) rather than
|
||||
// stored, so nothing can disagree with the XP total.
|
||||
//
|
||||
// The reward is deliberately prestige-only — a derived rank title, a cosmetic
|
||||
// marker on the sheet/leaderboard, and a small capped bundle of *activity*
|
||||
// bonuses (loot quality, XP, death avoidance). It NEVER grants combat stats:
|
||||
// the balance corpus (SimulateCombat / the golden) must stay valid, so renown
|
||||
// touches only the AdvBonusSummary activity levers and only at the activity
|
||||
// call sites, never loadCombatBonuses.
|
||||
|
||||
// renownXPPerLevel is the overflow XP that buys one Renown level. Steep by
|
||||
// design (§B2): a capped L20 player earns ~750 XP per T5 dungeon win, so a
|
||||
// Renown level is dozens of endgame clears.
|
||||
const renownXPPerLevel = 25000
|
||||
|
||||
// Renown perk ladder. One small step every renownPerkStepLevels Renown levels,
|
||||
// capped at renownPerkMaxSteps steps. At the cap the perks total +20% XP / +15%
|
||||
// loot, matching a streak-30 grant's economic half — §B2's ceiling in total
|
||||
// power.
|
||||
//
|
||||
// The perks are deliberately ONLY the two combat-neutral levers of the
|
||||
// AdvBonusSummary: combat_stats.go maps DeathModifier→Defense,
|
||||
// SuccessBonus→Attack and ExceptionalBonus→CritRate, so those *are* combat
|
||||
// stats and are off-limits (§B2: never combat-stat inflation, the balance
|
||||
// corpus must stay valid). LootQuality and XPMultiplier are read only by the
|
||||
// loot/XP economy, never by combat stat derivation — so renown can pay them out
|
||||
// even through loadCombatBonuses without moving the golden. That is why §B2's
|
||||
// suggested "−death penalty" perk is intentionally NOT granted: it would inflate
|
||||
// Defense.
|
||||
const (
|
||||
renownPerkStepLevels = 3
|
||||
renownPerkMaxSteps = 10
|
||||
)
|
||||
|
||||
// renownLevelFor derives the Renown level from cumulative overflow XP.
|
||||
func renownLevelFor(renownXP int) int {
|
||||
if renownXP <= 0 {
|
||||
return 0
|
||||
}
|
||||
return renownXP / renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownXPIntoLevel returns (progress, cost) toward the next Renown level, for
|
||||
// display. cost is always renownXPPerLevel.
|
||||
func renownXPIntoLevel(renownXP int) (int, int) {
|
||||
if renownXP < 0 {
|
||||
renownXP = 0
|
||||
}
|
||||
return renownXP % renownXPPerLevel, renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownRank is one rung of the derived title ladder: the first Renown level at
|
||||
// which the rank applies, and its name. A rank promotion (crossing into a new
|
||||
// rung) is what gets announced in the games room; plain level-ups only DM.
|
||||
type renownRank struct {
|
||||
MinLevel int
|
||||
Name string
|
||||
}
|
||||
|
||||
var renownRanks = []renownRank{
|
||||
{1, "Renowned"},
|
||||
{3, "Storied"},
|
||||
{5, "Illustrious"},
|
||||
{10, "Fabled"},
|
||||
{15, "Mythic"},
|
||||
{20, "Ascendant"},
|
||||
{30, "Eternal"},
|
||||
}
|
||||
|
||||
// renownRankFor returns the rank name for a Renown level, or "" below level 1.
|
||||
func renownRankFor(level int) string {
|
||||
name := ""
|
||||
for _, r := range renownRanks {
|
||||
if level >= r.MinLevel {
|
||||
name = r.Name
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// renownMarker is the cosmetic prestige badge shown next to a name on the sheet
|
||||
// and leaderboard. Empty below Renown 1.
|
||||
func renownMarker(level int) string {
|
||||
if level < 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("✦%d", level)
|
||||
}
|
||||
|
||||
// applyRenownBonuses folds the capped renown perks into an AdvBonusSummary. It
|
||||
// touches ONLY LootQuality and XPMultiplier — the combat-neutral economy levers
|
||||
// — so it is safe to call anywhere the summary is built, including
|
||||
// loadCombatBonuses, without changing any combat stat (see the const block).
|
||||
func applyRenownBonuses(b *AdvBonusSummary, renownLevel int) {
|
||||
if b == nil || renownLevel < renownPerkStepLevels {
|
||||
return
|
||||
}
|
||||
steps := renownLevel / renownPerkStepLevels
|
||||
if steps > renownPerkMaxSteps {
|
||||
steps = renownPerkMaxSteps
|
||||
}
|
||||
b.XPMultiplier += float64(steps) * 2 // → +20% at the cap
|
||||
b.LootQuality += float64(steps) * 1.5 // → +15% at the cap
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
// renownAccrueSQL is the atomic cumulative += on player_meta.renown_xp (the
|
||||
// journal_pages pattern), returning the post-update total. Shared by the
|
||||
// standalone and transactional accrual paths so the SQL lives in one place.
|
||||
const renownAccrueSQL = `INSERT INTO player_meta (user_id, renown_xp) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET renown_xp = renown_xp + excluded.renown_xp
|
||||
RETURNING renown_xp`
|
||||
|
||||
// sqlQueryer is the subset of *sql.DB / *sql.Tx that accrueRenownXP needs, so
|
||||
// the accrual can run standalone or inside grantDnDXP's save transaction.
|
||||
type sqlQueryer interface {
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// accrueRenownXP adds delta (> 0) to renown_xp against any queryer and returns
|
||||
// the cumulative totals before and after. A single statement, safe against
|
||||
// concurrent grants — no lost update.
|
||||
func accrueRenownXP(q sqlQueryer, userID id.UserID, delta int) (before, after int, err error) {
|
||||
if err = q.QueryRow(renownAccrueSQL, string(userID), delta).Scan(&after); err != nil {
|
||||
return 0, 0, fmt.Errorf("accrue renown_xp: %w", err)
|
||||
}
|
||||
return after - delta, after, nil
|
||||
}
|
||||
|
||||
// addRenownXP is the standalone accrual (its own DB write). delta <= 0 is a
|
||||
// no-op read. grantDnDXP uses the transactional saveDnDCharacterWithOverflow
|
||||
// instead, so this is the API for callers not already saving a character.
|
||||
func addRenownXP(userID id.UserID, delta int) (before, after int, err error) {
|
||||
if delta <= 0 {
|
||||
cur, e := loadRenownXP(userID)
|
||||
return cur, cur, e
|
||||
}
|
||||
return accrueRenownXP(db.Get(), userID, delta)
|
||||
}
|
||||
|
||||
// saveDnDCharacterWithOverflow persists the character and converts overflow XP
|
||||
// (> 0) to Renown in one transaction, so a crash can neither drop the overflow
|
||||
// nor double-credit it. Returns the cumulative renown totals before/after.
|
||||
func saveDnDCharacterWithOverflow(c *DnDCharacter, overflow int) (before, after int, err error) {
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = saveDnDCharacterExec(tx, c); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if before, after, err = accrueRenownXP(tx, c.UserID, overflow); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return before, after, nil
|
||||
}
|
||||
|
||||
// loadRenownXP reads the cumulative overflow XP. Absent row == 0.
|
||||
func loadRenownXP(userID id.UserID) (int, error) {
|
||||
var xp int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return xp, err
|
||||
}
|
||||
|
||||
// renownLevelForUser is the per-user Renown level, for callers (leaderboard)
|
||||
// that don't already hold the overlay. Errors resolve to 0.
|
||||
func renownLevelForUser(userID id.UserID) int {
|
||||
xp, err := loadRenownXP(userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(xp)
|
||||
}
|
||||
|
||||
// ── Announce ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// announceRenown notifies the player of Renown level-ups (DM) and, when the
|
||||
// gain crossed into a new rank rung, the games room. Event-driven off a grant,
|
||||
// not a scheduled recap. from/to are Renown levels before/after the grant.
|
||||
func (p *AdventurePlugin) announceRenown(userID id.UserID, from, to int) {
|
||||
if p == nil || to <= from {
|
||||
return
|
||||
}
|
||||
gained := to - from
|
||||
rank := renownRankFor(to)
|
||||
if p.Client != nil {
|
||||
msg := fmt.Sprintf("✦ **Renown %d** ✦\n\nYou've earned %s past the level cap — your legend grows.",
|
||||
to, pluralLevels(gained))
|
||||
if rank != "" {
|
||||
msg += fmt.Sprintf("\nRank: **%s**.", rank)
|
||||
}
|
||||
if err := p.SendDM(userID, msg); err != nil {
|
||||
slog.Error("renown: level-up DM failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
// Games-room shout only on a rank promotion, to keep the room quiet.
|
||||
if renownRankFor(from) == rank {
|
||||
return
|
||||
}
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
name, _ := loadDisplayName(userID)
|
||||
if name == "" {
|
||||
name = string(userID)
|
||||
}
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"✦ **%s** reached **Renown %d — %s.** A legend of the realm.", name, to, rank))
|
||||
}
|
||||
|
||||
func pluralLevels(n int) string {
|
||||
if n == 1 {
|
||||
return "a Renown level"
|
||||
}
|
||||
return fmt.Sprintf("%d Renown levels", n)
|
||||
}
|
||||
238
internal/plugin/adventure_renown_test.go
Normal file
238
internal/plugin/adventure_renown_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newRenownTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func TestRenownLevelFor(t *testing.T) {
|
||||
cases := []struct{ xp, want int }{
|
||||
{0, 0},
|
||||
{-100, 0},
|
||||
{renownXPPerLevel - 1, 0},
|
||||
{renownXPPerLevel, 1},
|
||||
{renownXPPerLevel*3 + 12, 3},
|
||||
{renownXPPerLevel * 30, 30},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := renownLevelFor(c.xp); got != c.want {
|
||||
t.Errorf("renownLevelFor(%d) = %d, want %d", c.xp, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownXPIntoLevel(t *testing.T) {
|
||||
into, cost := renownXPIntoLevel(renownXPPerLevel + 500)
|
||||
if cost != renownXPPerLevel {
|
||||
t.Errorf("cost = %d, want %d", cost, renownXPPerLevel)
|
||||
}
|
||||
if into != 500 {
|
||||
t.Errorf("into = %d, want 500", into)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownRankLadderMonotonic(t *testing.T) {
|
||||
// Rank thresholds must strictly increase so renownRankFor is well-defined.
|
||||
for i := 1; i < len(renownRanks); i++ {
|
||||
if renownRanks[i].MinLevel <= renownRanks[i-1].MinLevel {
|
||||
t.Errorf("rank %d threshold %d not > %d", i, renownRanks[i].MinLevel, renownRanks[i-1].MinLevel)
|
||||
}
|
||||
}
|
||||
if renownRankFor(0) != "" {
|
||||
t.Errorf("rank at 0 should be empty")
|
||||
}
|
||||
if renownRankFor(1) != "Renowned" {
|
||||
t.Errorf("rank at 1 = %q, want Renowned", renownRankFor(1))
|
||||
}
|
||||
if renownRankFor(4) != "Storied" {
|
||||
t.Errorf("rank at 4 = %q, want Storied (threshold 3)", renownRankFor(4))
|
||||
}
|
||||
if renownRankFor(1000) != "Eternal" {
|
||||
t.Errorf("top rank = %q, want Eternal", renownRankFor(1000))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownMarker(t *testing.T) {
|
||||
if renownMarker(0) != "" {
|
||||
t.Errorf("marker at 0 should be empty")
|
||||
}
|
||||
if got := renownMarker(7); got != "✦7" {
|
||||
t.Errorf("marker(7) = %q, want ✦7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRenownBonuses_CombatNeutralAndCapped — renown grants only the two
|
||||
// combat-neutral economy levers (loot/XP), capped at +15%/+20%, and must never
|
||||
// touch a lever that combat_stats.go maps to a combat stat. §B2.
|
||||
func TestApplyRenownBonuses_CombatNeutralAndCapped(t *testing.T) {
|
||||
// Below the first step: no effect.
|
||||
b := &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 2)
|
||||
if b.XPMultiplier != 0 || b.LootQuality != 0 {
|
||||
t.Errorf("renown < step should be inert, got %+v", b)
|
||||
}
|
||||
|
||||
// One step at renown 3.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 3)
|
||||
if b.XPMultiplier != 2 || b.LootQuality != 1.5 {
|
||||
t.Errorf("one step wrong: %+v", b)
|
||||
}
|
||||
|
||||
// At renown 30 and beyond, capped at +20% XP / +15% loot.
|
||||
for _, lvl := range []int{30, 45, 300} {
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, lvl)
|
||||
if b.XPMultiplier != 20 || b.LootQuality != 15 {
|
||||
t.Errorf("renown %d not capped: %+v", lvl, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Never combat-stat inflation: the levers combat_stats.go reads
|
||||
// (DeathModifier→Defense, SuccessBonus→Attack, ExceptionalBonus→CritRate)
|
||||
// and the skill/combat levers must all stay at zero at any renown level.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 300)
|
||||
if b.DeathModifier != 0 || b.SuccessBonus != 0 || b.ExceptionalBonus != 0 {
|
||||
t.Errorf("renown leaked into a combat-stat lever: %+v", b)
|
||||
}
|
||||
if b.CombatBonus != 0 || b.MiningBonus != 0 || b.ForagingBonus != 0 || b.FishingBonus != 0 {
|
||||
t.Errorf("renown leaked into combat/skill levers: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRenownXP_Accumulates — cumulative += with correct before/after.
|
||||
func TestAddRenownXP_Accumulates(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_add:example")
|
||||
if err := createAdvCharacter(uid, "Renowner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, after, err := addRenownXP(uid, 10000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 0 || after != 10000 {
|
||||
t.Errorf("first add: before=%d after=%d, want 0/10000", before, after)
|
||||
}
|
||||
|
||||
before, after, err = addRenownXP(uid, 20000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 10000 || after != 30000 {
|
||||
t.Errorf("second add: before=%d after=%d, want 10000/30000", before, after)
|
||||
}
|
||||
|
||||
got, err := loadRenownXP(uid)
|
||||
if err != nil || got != 30000 {
|
||||
t.Errorf("loadRenownXP = %d (err %v), want 30000", got, err)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renownLevelForUser = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownAtLeast — the B4 achievement gate reads the derived level correctly.
|
||||
func TestRenownAtLeast(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_ach:example")
|
||||
if err := createAdvCharacter(uid, "Achiever"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := db.Get()
|
||||
if renownAtLeast(d, uid, 1) {
|
||||
t.Errorf("no renown yet, should not meet level 1")
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !renownAtLeast(d, uid, 5) {
|
||||
t.Errorf("renown 5 should meet level 5")
|
||||
}
|
||||
if renownAtLeast(d, uid, 6) {
|
||||
t.Errorf("renown 5 should not meet level 6")
|
||||
}
|
||||
// Absent player → false, not a crash.
|
||||
if renownAtLeast(d, id.UserID("@nobody:nowhere.invalid"), 1) {
|
||||
t.Errorf("absent player should not meet any renown level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownOverlay — applyPlayerMetaOverlay populates RenownXP and RenownLevel.
|
||||
func TestRenownOverlay(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_overlay:example")
|
||||
if err := createAdvCharacter(uid, "Overlaid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5+7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := loadAdvCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if c.RenownXP != renownXPPerLevel*5+7 {
|
||||
t.Errorf("overlay RenownXP = %d, want %d", c.RenownXP, renownXPPerLevel*5+7)
|
||||
}
|
||||
if c.RenownLevel() != 5 {
|
||||
t.Errorf("RenownLevel() = %d, want 5", c.RenownLevel())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantDnDXP_OverflowBecomesRenown — at the cap, grantDnDXP routes overflow
|
||||
// into renown_xp and zeroes dnd_xp (the pre-N7 cap invariant is preserved).
|
||||
func TestGrantDnDXP_OverflowBecomesRenown(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_grant:example")
|
||||
if err := createAdvCharacter(uid, "Capped"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: dndMaxLevel,
|
||||
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12, ArmorClass: 16,
|
||||
}
|
||||
c.HPMax = 100
|
||||
c.HPCurrent = 100
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
events, err := p.grantDnDXP(uid, renownXPPerLevel+5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("got %d level-up events at cap, want 0", len(events))
|
||||
}
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Level != dndMaxLevel {
|
||||
t.Errorf("level = %d, want %d", got.Level, dndMaxLevel)
|
||||
}
|
||||
if got.XP != 0 {
|
||||
t.Errorf("dnd_xp = %d, want 0 (overflow moved to renown)", got.XP)
|
||||
}
|
||||
if xp, _ := loadRenownXP(uid); xp != renownXPPerLevel+5000 {
|
||||
t.Errorf("renown_xp = %d, want %d", xp, renownXPPerLevel+5000)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renown level = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
@@ -164,21 +164,43 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
gaveCard = true
|
||||
}
|
||||
|
||||
// Update visit count
|
||||
// Update visit count, and every 10th visit leave a small consumable
|
||||
// "for the trouble" (D2 NPC arc).
|
||||
var leftGift *AdvItem
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil {
|
||||
char.RobbieVisitCount++
|
||||
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
|
||||
// Use the canonical DnD level (like the arena's tier gate), not the
|
||||
// frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup
|
||||
// completes, so reading it here would peg every gift at tier 1.
|
||||
if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 {
|
||||
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
||||
leftGift = &gifts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char))
|
||||
}
|
||||
|
||||
// Send DM
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGift)
|
||||
if err := p.SendDM(userID, dm); err != nil {
|
||||
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)
|
||||
@@ -191,10 +213,12 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||
var result []AdvItem
|
||||
for _, item := range inv {
|
||||
// Never touch Arena gear, cards, or consumables. Consumables are a
|
||||
// player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
|
||||
// Never touch Arena gear, cards, consumables, or keys. Consumables are
|
||||
// a player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves. Keys are cross-zone
|
||||
// unlock tokens (N5/D4) that must persist in inventory to open their
|
||||
// vault later — sweeping one permanently breaks that unlock.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -243,7 +267,27 @@ func robbiePlayerHasCard(userID id.UserID) bool {
|
||||
|
||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
|
||||
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind.
|
||||
const robbieGiftEveryNVisits = 10
|
||||
|
||||
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
||||
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
||||
func robbieGiftTier(level int) int {
|
||||
switch {
|
||||
case level >= 18:
|
||||
return 5
|
||||
case level >= 13:
|
||||
return 4
|
||||
case level >= 8:
|
||||
return 3
|
||||
case level >= 4:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGift *AdvItem) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Opening
|
||||
@@ -290,6 +334,12 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Every-10th-visit consumable (D2).
|
||||
if leftGift != nil {
|
||||
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Closing
|
||||
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
|
||||
sb.WriteString(closing)
|
||||
|
||||
@@ -163,6 +163,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
holidayLabel := ""
|
||||
@@ -404,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
|
||||
@@ -420,6 +422,12 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
|
||||
dmsSent := 0
|
||||
for _, char := range chars {
|
||||
// Advance this player's Shadow (N6/D3) once for the day, before any
|
||||
// streak/idle branching below — the rival runs whether or not the
|
||||
// player did, which is the whole point of the race pressure. Own table,
|
||||
// own idempotency guard; never touches char, never fails the reset.
|
||||
p.advanceShadow(&char)
|
||||
|
||||
// Died inside the window — no shame, no streak change. Covers both
|
||||
// currently-dead players and players revived at midnight (Alive
|
||||
// already flipped to true by the reminder loop before this runs).
|
||||
@@ -450,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.
|
||||
|
||||
199
internal/plugin/adventure_season.go
Normal file
199
internal/plugin/adventure_season.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/E4 — Seasonal events (gogobee_engagement_plan.md §E4).
|
||||
//
|
||||
// A season is a 1-week skin anchored to a real holiday. Unlike isHolidayToday()
|
||||
// (a single UTC day, worth a double-action) and unlike the Omen (an ISO-week
|
||||
// rotation), a season spans a window around its anchor date and layers three
|
||||
// things on top of the world for that week:
|
||||
//
|
||||
// 1. A themed world modifier — a reskinned Omen that OVERRIDES the weekly
|
||||
// rotation (see activeOmen). It reuses the omen effect fields, so the same
|
||||
// non-combat rule holds: a season never touches SimulateCombat or the turn
|
||||
// engine. That is enforced structurally — activeSeason() honours
|
||||
// simOmenDisabled exactly as activeOmen() does, so the balance sim never
|
||||
// sees a season through any path, and the season Omen only reaches the sim's
|
||||
// omen seams behind activeOmen's own simOmenDisabled guard.
|
||||
// 2. A limited-time curio shelf at Luigi's — a curated selection of existing
|
||||
// registry items (dailyCuriosStock). No new power enters the game; the shelf
|
||||
// just changes which items rotate in for the week.
|
||||
// 3. A themed visitor on the road — a season-gated ambient event that leaves a
|
||||
// small keepsake and a coin gift (expedition_ambient.go). No combat: the
|
||||
// ambient seam has never opened a fight and this does not change that.
|
||||
//
|
||||
// A season is a pure function of the UTC date and the anchor calendar, so it
|
||||
// needs no schema, no ticker state, and no persistence — the same discipline as
|
||||
// the Omen and the holiday calendar.
|
||||
|
||||
// seasonHalfWidth is the number of days on each side of the anchor date that the
|
||||
// season is live. 3 → a 7-day window (anchor ± 3).
|
||||
const seasonHalfWidth = 3
|
||||
|
||||
// seasonVisitor is the themed ambient event a season adds to the road. It never
|
||||
// resolves combat — it leaves a sellable keepsake and a small coin gift.
|
||||
type seasonVisitor struct {
|
||||
Weight int // relative weight in the ambient pick
|
||||
FlavorPool []string // scene narration lines for the visit
|
||||
Keepsake string // sellable trophy left behind
|
||||
KeepsakeValue int64 // coin baseline of the keepsake
|
||||
Coins int // flat coin gift
|
||||
}
|
||||
|
||||
// season is one holiday skin.
|
||||
type season struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing, e.g. "Hallowtide"
|
||||
Emoji string // banner emoji
|
||||
Anchor func(year int) time.Time // the anchor date for a given year (UTC)
|
||||
Blurb string // one-line "what's live this week" banner copy
|
||||
Omen omen // themed override omen (non-combat fields only)
|
||||
CurioIDs []string // curated registry IDs for Luigi's shelf
|
||||
Visitor seasonVisitor // the road visitor
|
||||
}
|
||||
|
||||
// fixedAnchor builds an Anchor for a fixed month/day holiday.
|
||||
func fixedAnchor(month time.Month, day int) func(int) time.Time {
|
||||
return func(year int) time.Time {
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
// seasonTable is the set of anchor holidays that get a skin. Order is match
|
||||
// order; the windows are disjoint so it never matters, but keep them disjoint.
|
||||
var seasonTable = []season{
|
||||
{
|
||||
Key: "hallowtide", Name: "Hallowtide", Emoji: "🎃",
|
||||
Anchor: fixedAnchor(time.October, 31),
|
||||
Blurb: "the veil's thin — spooky curios at Luigi's and things going bump on the road, all week",
|
||||
Omen: omen{
|
||||
Key: "hallowtide", Name: "Hallowtide",
|
||||
TwinBee: "The veil's gone thin for Hallowtide — reagents and oddments are spilling through from somewhere I'd rather not name. I'm grabbing them while they're here.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"cloak_of_arachnida", "demon_armor", "goggles_of_night",
|
||||
"slippers_of_spider_climbing", "staff_of_swarming_insects",
|
||||
"sword_of_life_stealing", "dagger_of_venom", "cloak_of_the_bat",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A gourd-headed thing shuffles out of the dark, sets a little carved lantern on your bedroll, and shuffles right back into it.",
|
||||
"Something small and many-legged skitters past, drops a trinket at your feet as if in tribute, and is gone before you can look twice.",
|
||||
"A cold draft carries a whisper and a gift — a carved gourd, still faintly warm, left where you'll find it come morning.",
|
||||
},
|
||||
Keepsake: "Carved Gourd Lantern", KeepsakeValue: 60, Coins: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "midwinter", Name: "Midwinter Feast", Emoji: "❄️",
|
||||
Anchor: fixedAnchor(time.December, 25),
|
||||
Blurb: "gifts by every door — a free pack for anyone heading out, warm curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "midwinter", Name: "Midwinter Feast",
|
||||
TwinBee: "It's Midwinter, and someone's been leaving gifts by the outfitter's door. There's a free pack in it for anyone heading out — and I set off in high spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"boots_of_the_winterlands", "frost_brand", "ring_of_warmth",
|
||||
"staff_of_frost", "potion_of_healing", "staff_of_healing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A bundled figure passes your camp without a word, leaves a frost-glass bauble hanging where the firelight catches it, and trudges on into the snow.",
|
||||
"You wake to find your pack a little heavier — a wrapped bauble and a handful of coin, and a single set of bootprints leading away.",
|
||||
"Bells, faint and far off. By the time they fade there's a bauble on your bedroll that wasn't there before.",
|
||||
},
|
||||
Keepsake: "Frost-Glass Bauble", KeepsakeValue: 60, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel", Emoji: "💗",
|
||||
Anchor: fixedAnchor(time.February, 14),
|
||||
Blurb: "the arena crowd's throwing coin — fat purses, charming curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel",
|
||||
TwinBee: "The whole town's giddy for Sweethearts' week — the arena crowd's throwing coin like confetti. Win pretty and the purse pays twenty over the odds.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"eyes_of_charming", "philter_of_love", "staff_of_charming",
|
||||
"luck_blade", "stone_of_good_luck_luckstone", "pearl_of_power",
|
||||
"glamoured_studded_leather",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A courier in festival colours finds you even out here, presses a ribbon-wrapped token into your hand with a wink, and hurries back the way they came.",
|
||||
"A paper heart, pinned to a token and left on your pack — 'from an admirer,' it says, and nothing else.",
|
||||
"Someone's left a ribbon-wrapped keepsake by the trail marker, addressed to no one and everyone. You pocket it.",
|
||||
},
|
||||
Keepsake: "Ribbon-Wrapped Token", KeepsakeValue: 55, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "first_bloom", Name: "First Bloom", Emoji: "🌷",
|
||||
Anchor: func(year int) time.Time { return easterDate(year) },
|
||||
Blurb: "everything's growing eager — fuller harvests, green curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "first_bloom", Name: "First Bloom",
|
||||
TwinBee: "First Bloom's on us — everything's growing twice as eager and the woods feel calm with it. Every gather comes up fuller, and the dread's slow to rise.",
|
||||
HarvestYieldBonus: 1,
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"bag_of_beans", "potion_of_growth", "potion_of_animal_friendship",
|
||||
"ring_of_animal_influence", "horn_of_valhalla", "sword_of_life_stealing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A hare the size of a hound lopes up, regards you with unsettling calm, and leaves a pressed blossom on the ground before bounding off.",
|
||||
"New vines have crept over your gear in the night — and tucked among them, a perfect pressed blossom and a little coin, as if the woods were paying rent.",
|
||||
"The undergrowth parts on its own, sets a spring blossom at your feet, and closes again. You decide not to question it.",
|
||||
},
|
||||
Keepsake: "Pressed Spring Blossom", KeepsakeValue: 50, Coins: 8,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// activeSeason returns the season whose window contains today (UTC), or false.
|
||||
// Honours simOmenDisabled so the balance sim never observes a season through any
|
||||
// path — the same neutralisation activeOmen applies to the weekly rotation.
|
||||
func activeSeason() (season, bool) {
|
||||
if simOmenDisabled {
|
||||
return season{}, false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return seasonForDate(today)
|
||||
}
|
||||
|
||||
// seasonForDate is the pure core of activeSeason, split out for tests. The
|
||||
// anchor is checked against the target year and its neighbours so a window that
|
||||
// straddles a year boundary still resolves.
|
||||
func seasonForDate(today time.Time) (season, bool) {
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{today.Year() - 1, today.Year(), today.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
lo := a.AddDate(0, 0, -seasonHalfWidth)
|
||||
hi := a.AddDate(0, 0, seasonHalfWidth)
|
||||
if !today.Before(lo) && !today.After(hi) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return season{}, false
|
||||
}
|
||||
|
||||
// seasonBannerLine is the "what's live this week" one-liner surfaced under the
|
||||
// Omen line in the morning DM. Empty when no season is active.
|
||||
func seasonBannerLine(s season) string {
|
||||
return s.Emoji + " **" + s.Name + "** is here — " + s.Blurb + "."
|
||||
}
|
||||
120
internal/plugin/adventure_season_test.go
Normal file
120
internal/plugin/adventure_season_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func date(y int, m time.Month, d int) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// TestSeasonForDate_ActiveOnAnchor — every season resolves on its own anchor day
|
||||
// and both window edges (±seasonHalfWidth), and is dark one day past each edge.
|
||||
func TestSeasonForDate_ActiveOnAnchor(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
a := s.Anchor(2026)
|
||||
for _, off := range []int{-seasonHalfWidth, 0, seasonHalfWidth} {
|
||||
got, ok := seasonForDate(a.AddDate(0, 0, off))
|
||||
if !ok || got.Key != s.Key {
|
||||
t.Errorf("%s: day offset %d → (%v, %q), want (true, %q)",
|
||||
s.Key, off, ok, got.Key, s.Key)
|
||||
}
|
||||
}
|
||||
for _, off := range []int{-seasonHalfWidth - 1, seasonHalfWidth + 1} {
|
||||
if got, ok := seasonForDate(a.AddDate(0, 0, off)); ok {
|
||||
t.Errorf("%s: day offset %d should be dark, got %q", s.Key, off, got.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonForDate_WindowsDisjoint — no calendar day resolves to two seasons, so
|
||||
// the first-match order in seasonForDate never hides overlapping content.
|
||||
func TestSeasonForDate_WindowsDisjoint(t *testing.T) {
|
||||
for y := 2024; y <= 2030; y++ {
|
||||
day := date(y, time.January, 1)
|
||||
end := date(y+1, time.January, 1)
|
||||
for day.Before(end) {
|
||||
matches := 0
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{day.Year() - 1, day.Year(), day.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
if !day.Before(a.AddDate(0, 0, -seasonHalfWidth)) &&
|
||||
!day.After(a.AddDate(0, 0, seasonHalfWidth)) {
|
||||
matches++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches > 1 {
|
||||
t.Fatalf("%s resolves to %d seasons (windows must be disjoint)",
|
||||
day.Format("2006-01-02"), matches)
|
||||
}
|
||||
day = day.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_CuratedItemsExist — every curated curio ID is a real registry
|
||||
// item, so a season's shelf never lists a phantom the buyer can't purchase. Guards
|
||||
// against typos and registry edits (mirrors TestTemperMaterialsAreObtainable).
|
||||
func TestSeasonTable_CuratedItemsExist(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
if len(s.CurioIDs) == 0 {
|
||||
t.Errorf("%s has no curated curios", s.Key)
|
||||
}
|
||||
for _, id := range s.CurioIDs {
|
||||
if _, ok := magicItemRegistry[id]; !ok {
|
||||
t.Errorf("%s curio %q is not in magicItemRegistry", s.Key, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_OmenIsNonCombat — a season's themed omen carries an effect and
|
||||
// only touches the non-combat omen levers, so overriding the weekly rotation with
|
||||
// it can never move the combat golden or the balance corpus. §E4/§B3.
|
||||
func TestSeasonTable_OmenIsNonCombat(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
o := s.Omen
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("%s omen has no active effect", s.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("%s omen missing player-facing copy", s.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_VisitorRewardComplete — every season's road visitor has flavor,
|
||||
// a named keepsake, and a positive keepsake value, so applyAmbientEffect always
|
||||
// has a real reward to grant.
|
||||
func TestSeasonTable_VisitorRewardComplete(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
v := s.Visitor
|
||||
if len(v.FlavorPool) == 0 {
|
||||
t.Errorf("%s visitor has no flavor", s.Key)
|
||||
}
|
||||
if v.Keepsake == "" || v.KeepsakeValue <= 0 {
|
||||
t.Errorf("%s visitor keepsake incomplete: %q value %d", s.Key, v.Keepsake, v.KeepsakeValue)
|
||||
}
|
||||
if v.Weight <= 0 {
|
||||
t.Errorf("%s visitor has non-positive weight %d", s.Key, v.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveSeason_NeutralizedInSim — the balance sim must never observe a season
|
||||
// through any path; activeSeason honours simOmenDisabled exactly as activeOmen does.
|
||||
func TestActiveSeason_NeutralizedInSim(t *testing.T) {
|
||||
prev := simOmenDisabled
|
||||
simOmenDisabled = true
|
||||
defer func() { simOmenDisabled = prev }()
|
||||
if s, ok := activeSeason(); ok {
|
||||
t.Fatalf("activeSeason returned %q while simOmenDisabled", s.Key)
|
||||
}
|
||||
}
|
||||
216
internal/plugin/adventure_secret_room_test.go
Normal file
216
internal/plugin/adventure_secret_room_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// registeredSecretNodes collects every NodeKindSecret node across all
|
||||
// registered zone graphs, keyed by node ID.
|
||||
func registeredSecretNodes(t *testing.T) map[string]ZoneNode {
|
||||
t.Helper()
|
||||
out := map[string]ZoneNode{}
|
||||
for _, z := range allZones() {
|
||||
g, ok := loadZoneGraph(z.ID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for id, n := range g.Nodes {
|
||||
if n.Kind == NodeKindSecret {
|
||||
out[id] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSecretRoomDiscovery_EverySecretHasBespokeFlavor guards that no secret
|
||||
// node ships on the generic fallback — every one gets an authored line. Catches
|
||||
// a future secret room added without a discovery entry.
|
||||
func TestSecretRoomDiscovery_EverySecretHasBespokeFlavor(t *testing.T) {
|
||||
for id, n := range registeredSecretNodes(t) {
|
||||
if _, ok := secretRoomDiscovery[id]; !ok {
|
||||
t.Errorf("secret node %q (%q) has no bespoke discovery line", id, n.Label)
|
||||
}
|
||||
if line := secretRoomDiscoveryLine(n); strings.TrimSpace(line) == "" {
|
||||
t.Errorf("secret node %q produced an empty discovery line", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRoomDiscoveryLine_FallsBackOnUnknownNode(t *testing.T) {
|
||||
n := ZoneNode{NodeID: "made_up.node", Kind: NodeKindSecret, Label: "Nowhere Nook"}
|
||||
line := secretRoomDiscoveryLine(n)
|
||||
if !strings.Contains(line, "Nowhere Nook") {
|
||||
t.Errorf("fallback should name the node label, got: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretRoomKeys_UnlockRealDestinationEdges is the cross-zone-key
|
||||
// consistency check: every key's item Name must match a LockKey edge's key_id
|
||||
// (lower-cased) in its destination zone, and every source must be a real secret
|
||||
// room. A typo between the granted item name and the authored key_id would
|
||||
// silently make a vault permanently unreachable; this catches it.
|
||||
func TestSecretRoomKeys_UnlockRealDestinationEdges(t *testing.T) {
|
||||
secrets := registeredSecretNodes(t)
|
||||
for srcNode, key := range secretRoomKeys {
|
||||
if _, ok := secrets[srcNode]; !ok {
|
||||
t.Errorf("key source %q is not a registered secret room", srcNode)
|
||||
}
|
||||
g, ok := loadZoneGraph(key.unlocksIn)
|
||||
if !ok {
|
||||
t.Errorf("%s: destination zone %q has no graph", srcNode, key.unlocksIn)
|
||||
continue
|
||||
}
|
||||
wantKeyID := strings.ToLower(key.item.Name)
|
||||
found := false
|
||||
for _, outs := range g.Edges {
|
||||
for _, e := range outs {
|
||||
if e.Lock == LockKey && strings.ToLower(lockDataString(e.LockData, "key_id")) == wantKeyID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("key %q (from %s) has no LockKey edge with key_id=%q in zone %q — vault unreachable",
|
||||
key.item.Name, srcNode, wantKeyID, key.unlocksIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossZoneKey_UnlocksWithItemNotWithout exercises the runtime lock
|
||||
// evaluation end-to-end on a real authored edge.
|
||||
func TestCrossZoneKey_UnlocksWithItemNotWithout(t *testing.T) {
|
||||
g, ok := loadZoneGraph(ZoneManorBlackspire)
|
||||
if !ok {
|
||||
t.Fatal("manor graph missing")
|
||||
}
|
||||
var keyEdge ZoneEdge
|
||||
for _, e := range g.outgoingEdges("manor_blackspire.upper_hall") {
|
||||
if e.Lock == LockKey {
|
||||
keyEdge = e
|
||||
}
|
||||
}
|
||||
if keyEdge.To == "" {
|
||||
t.Fatal("no LockKey edge off manor upper_hall")
|
||||
}
|
||||
|
||||
base := edgeUnlockCtx{RunID: "r", FromNode: "manor_blackspire.upper_hall"}
|
||||
if ok, _ := evaluateEdgeLock(keyEdge, base); ok {
|
||||
t.Error("edge should be locked without the key in inventory")
|
||||
}
|
||||
|
||||
withKey := base
|
||||
withKey.InventoryNames = map[string]bool{"sunken sigil": true}
|
||||
if ok, reason := evaluateEdgeLock(keyEdge, withKey); !ok {
|
||||
t.Errorf("edge should unlock with the sigil in inventory, got locked: %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantSecretRoomKey_GrantsOnceIdempotent verifies a key-bearing secret
|
||||
// hands its key over exactly once.
|
||||
func TestGrantSecretRoomKey_GrantsOnceIdempotent(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@keyholder:test.invalid")
|
||||
|
||||
node := ZoneNode{NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret}
|
||||
line := p.grantSecretRoomKey(u, node)
|
||||
if !strings.Contains(line, "Sunken Sigil") {
|
||||
t.Fatalf("first grant should name the key, got: %q", line)
|
||||
}
|
||||
|
||||
items, err := loadAdvInventory(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load inventory: %v", err)
|
||||
}
|
||||
sigils := 0
|
||||
for _, it := range items {
|
||||
if strings.EqualFold(it.Name, "Sunken Sigil") {
|
||||
sigils++
|
||||
}
|
||||
}
|
||||
if sigils != 1 {
|
||||
t.Fatalf("want exactly 1 Sunken Sigil, got %d", sigils)
|
||||
}
|
||||
|
||||
// Second find on a later run must not stack a duplicate.
|
||||
if again := p.grantSecretRoomKey(u, node); again != "" {
|
||||
t.Errorf("re-finding the room should not re-grant the key, got: %q", again)
|
||||
}
|
||||
items, _ = loadAdvInventory(u)
|
||||
sigils = 0
|
||||
for _, it := range items {
|
||||
if strings.EqualFold(it.Name, "Sunken Sigil") {
|
||||
sigils++
|
||||
}
|
||||
}
|
||||
if sigils != 1 {
|
||||
t.Fatalf("still want exactly 1 Sunken Sigil after re-find, got %d", sigils)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantSecretRoomKey_NoKeyForPlainSecret confirms a secret room that carries
|
||||
// no cross-zone key stays silent.
|
||||
func TestGrantSecretRoomKey_NoKeyForPlainSecret(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@plain:test.invalid")
|
||||
node := ZoneNode{NodeID: "forest_shadows.sapling_shrine", Kind: NodeKindSecret}
|
||||
if line := p.grantSecretRoomKey(u, node); line != "" {
|
||||
t.Errorf("plain secret should grant no key, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSecretRoom_GrantsPageCacheAndKey checks the full treasure-cache
|
||||
// payout: a journal page, the consumable cache, and (for a key-bearing secret)
|
||||
// the cross-zone key — with no combat touching HP.
|
||||
func TestResolveSecretRoom_GrantsPageCacheAndKey(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@secret:test.invalid")
|
||||
|
||||
zone, ok := getZone(ZoneSunkenTemple)
|
||||
if !ok {
|
||||
t.Fatal("sunken temple zone missing")
|
||||
}
|
||||
node := ZoneNode{
|
||||
NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret,
|
||||
Label: "Coral Reliquary",
|
||||
Content: ZoneNodeContent{LootBias: 1.8},
|
||||
}
|
||||
run := &DungeonRun{UserID: string(u), ZoneID: ZoneSunkenTemple, CurrentNode: node.NodeID}
|
||||
|
||||
out := p.resolveSecretRoom(u, run, zone, node)
|
||||
if !strings.Contains(out, "Coral Reliquary") {
|
||||
t.Errorf("outcome should carry the discovery line:\n%s", out)
|
||||
}
|
||||
|
||||
// A journal page was granted (fresh player, nothing found yet).
|
||||
if mask, err := loadJournalPages(u); err != nil || journalPageCount(mask) != 1 {
|
||||
t.Fatalf("want exactly 1 journal page granted, got count=%d err=%v", journalPageCount(mask), err)
|
||||
}
|
||||
|
||||
// The guaranteed cache + the cross-zone key are in inventory.
|
||||
items, err := loadAdvInventory(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load inventory: %v", err)
|
||||
}
|
||||
var caches, keys int
|
||||
for _, it := range items {
|
||||
switch {
|
||||
case it.Type == "key":
|
||||
keys++
|
||||
case it.Type == "consumable":
|
||||
caches++
|
||||
}
|
||||
}
|
||||
if caches != secretRoomCacheCount {
|
||||
t.Errorf("want %d cache consumables, got %d", secretRoomCacheCount, caches)
|
||||
}
|
||||
if keys != 1 {
|
||||
t.Errorf("want the Sunken Sigil key granted, got %d keys", keys)
|
||||
}
|
||||
}
|
||||
432
internal/plugin/adventure_shadow.go
Normal file
432
internal/plugin/adventure_shadow.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The Shadow (N6/D3) — a per-player simulated rival adventurer. It "runs" the
|
||||
// same zone progression the player does, advanced once per UTC day by the
|
||||
// midnight ticker at ~1.3x the player's own long-run clear pace, so it stays
|
||||
// just ahead: a rival you can always see and always nearly catch.
|
||||
//
|
||||
// It is pure theatre. There is no Shadow combat, no punishment, no state the
|
||||
// player can lose. The only mechanics are race pressure (a morning-briefing
|
||||
// line telling you where it is relative to you) and two payoffs at each zone
|
||||
// clear: beat the Shadow to a zone and TwinBee crows a little bonus XP; let the
|
||||
// Shadow clear it first and it leaves a journal page waiting in the boss room
|
||||
// (the D1 campaign tie-in). All Shadow narration TwinBee speaks is first-person,
|
||||
// he/him, implicit-subject — the campaign voice rules (feedback_twinbee_voice,
|
||||
// feedback_twinbee_is_male). The Shadow itself is referred to as "the Shadow"
|
||||
// (or by its name); "he" in TwinBee's lines is TwinBee, never a third-person
|
||||
// "TwinBee".
|
||||
|
||||
const (
|
||||
// shadowPaceMultiplier keeps the Shadow just ahead: it clears at ~1.3x the
|
||||
// player's own long-run pace.
|
||||
shadowPaceMultiplier = 1.3
|
||||
// shadowMinDailyStep is the floor on a day's advance, so a brand-new or idle
|
||||
// player still watches the rival creep forward rather than sit frozen.
|
||||
shadowMinDailyStep = 0.2
|
||||
// shadowMaxDailyStep caps a day's advance at just under one zone, so even a
|
||||
// prolific player never sees the Shadow clear two zones overnight.
|
||||
shadowMaxDailyStep = 1.0
|
||||
// shadowMaxLead caps how far ahead the Shadow may run — it is a race, not a
|
||||
// runaway. It will never sit more than ~2.5 zones past the player's own count.
|
||||
shadowMaxLead = 2.5
|
||||
// shadowCrowXPPerTier scales the "you beat me here" bonus by zone tier, so a
|
||||
// T5 crow is worth more than a T1 one. Small on purpose (lift, not a spike).
|
||||
shadowCrowXPPerTier = 12
|
||||
)
|
||||
|
||||
// shadowState mirrors the adventure_shadow row.
|
||||
type shadowState struct {
|
||||
UserID id.UserID
|
||||
Name string
|
||||
Progress float64
|
||||
ZonesCleared int
|
||||
PendingMask int64
|
||||
CrowedMask int64
|
||||
DayCounter int
|
||||
LastAdvanced string
|
||||
}
|
||||
|
||||
// The Shadow walks zoneOrder — the design-doc order the zone registry is built
|
||||
// in — start to finish, tier 1 → 5. Bit i of a Shadow's masks is zoneOrder[i].
|
||||
|
||||
// shadowZoneIndex returns the progression index of a zone, or -1 if it is not
|
||||
// on the path (the arena, an unknown id).
|
||||
func shadowZoneIndex(z ZoneID) int {
|
||||
for i, id := range zoneOrder {
|
||||
if id == z {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func shadowSetBit(mask int64, idx int) int64 {
|
||||
if idx < 0 || idx > 62 {
|
||||
return mask
|
||||
}
|
||||
return mask | (int64(1) << idx)
|
||||
}
|
||||
|
||||
func shadowClearBit(mask int64, idx int) int64 {
|
||||
if idx < 0 || idx > 62 {
|
||||
return mask
|
||||
}
|
||||
return mask &^ (int64(1) << idx)
|
||||
}
|
||||
|
||||
func shadowBitSet(mask int64, idx int) bool {
|
||||
if idx < 0 || idx > 62 {
|
||||
return false
|
||||
}
|
||||
return mask&(int64(1)<<idx) != 0
|
||||
}
|
||||
|
||||
// loadShadow reads a player's Shadow, or (nil, nil) if it hasn't been born yet.
|
||||
func loadShadow(userID id.UserID) (*shadowState, error) {
|
||||
s := &shadowState{UserID: userID}
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced
|
||||
FROM adventure_shadow WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&s.Name, &s.Progress, &s.ZonesCleared, &s.PendingMask, &s.CrowedMask, &s.DayCounter, &s.LastAdvanced)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// upsertShadow persists the whole Shadow row. The ticker is the only writer, so
|
||||
// a plain last-write-wins upsert is safe — there is no concurrent mutator to
|
||||
// race (unlike the player_meta columns a character save also touches).
|
||||
func upsertShadow(s *shadowState) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO adventure_shadow
|
||||
(user_id, name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
progress = excluded.progress,
|
||||
zones_cleared = excluded.zones_cleared,
|
||||
pending_mask = excluded.pending_mask,
|
||||
crowed_mask = excluded.crowed_mask,
|
||||
day_counter = excluded.day_counter,
|
||||
last_advanced = excluded.last_advanced`,
|
||||
string(s.UserID), s.Name, s.Progress, s.ZonesCleared, s.PendingMask,
|
||||
s.CrowedMask, s.DayCounter, s.LastAdvanced,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// shadowNames is the pool the rival's proper name is drawn from — cold,
|
||||
// half-familiar names for a thing that shadows you. Picked deterministically
|
||||
// from the player's own name so a given player always faces the same Shadow.
|
||||
var shadowNames = []string{
|
||||
"Vael", "Corriss", "Mordane", "Ashen", "Locke", "Grimma", "Sable",
|
||||
"Thane", "Wren", "Vesper", "Cael", "Orrin", "Dain", "Wraithe",
|
||||
"Nyx", "Solenne",
|
||||
}
|
||||
|
||||
// shadowNameFor seeds the Shadow's name from the player's display name so it is
|
||||
// stable per player and "seeded from the player's" (D3 spec). Falls back to the
|
||||
// user id when the display name is empty.
|
||||
func shadowNameFor(displayName string, userID id.UserID) string {
|
||||
seed := strings.ToLower(strings.TrimSpace(displayName))
|
||||
if seed == "" {
|
||||
seed = string(userID)
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(seed))
|
||||
return shadowNames[int(h.Sum32())%len(shadowNames)]
|
||||
}
|
||||
|
||||
// newShadow mints a fresh Shadow at the start line.
|
||||
func newShadow(userID id.UserID, displayName string) *shadowState {
|
||||
return &shadowState{
|
||||
UserID: userID,
|
||||
Name: shadowNameFor(displayName, userID),
|
||||
}
|
||||
}
|
||||
|
||||
// advanceShadow moves a player's Shadow forward one UTC day. Idempotent per day
|
||||
// via last_advanced, so the once-a-day midnight guard plus this makes a double
|
||||
// tick a no-op. Mints the Shadow on first call. Errors are logged and swallowed
|
||||
// — a stalled Shadow is cosmetic, and must never break the midnight reset loop.
|
||||
func (p *AdventurePlugin) advanceShadow(char *AdventureCharacter) {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
s, err := loadShadow(char.UserID)
|
||||
if err != nil {
|
||||
slog.Warn("shadow: load", "user", char.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
if s == nil {
|
||||
s = newShadow(char.UserID, char.DisplayName)
|
||||
}
|
||||
if s.LastAdvanced == today {
|
||||
return // already advanced this UTC day
|
||||
}
|
||||
|
||||
cleared := clearedZoneIDs(db.Get(), char.UserID)
|
||||
playerCount := len(cleared)
|
||||
|
||||
// The player's long-run pace, in zones per day, drives the Shadow's speed.
|
||||
// Floor it so a new/idle player still gets a creeping rival; the 1.3x keeps
|
||||
// the Shadow just ahead; cap the daily step so it never leaps two zones.
|
||||
step := (float64(playerCount) / float64(shadowPlayerAgeDays(char))) * shadowPaceMultiplier
|
||||
if step < shadowMinDailyStep {
|
||||
step = shadowMinDailyStep
|
||||
}
|
||||
if step > shadowMaxDailyStep {
|
||||
step = shadowMaxDailyStep
|
||||
}
|
||||
|
||||
newProg := s.Progress + step
|
||||
// Keep it a race: never more than shadowMaxLead zones past the player's count.
|
||||
if leadCap := float64(playerCount) + shadowMaxLead; newProg > leadCap {
|
||||
newProg = leadCap
|
||||
}
|
||||
if maxProg := float64(len(zoneOrder)); newProg > maxProg {
|
||||
newProg = maxProg
|
||||
}
|
||||
if newProg < s.Progress {
|
||||
newProg = s.Progress // never regress (a lead cap that dropped below current)
|
||||
}
|
||||
|
||||
// Any progression zone the Shadow newly finishes, and the player hasn't,
|
||||
// leaves a page waiting. Compare per-zone (not by count): the Shadow walks
|
||||
// in order while the player may clear out of order, so "did the Shadow clear
|
||||
// zone i first" is a per-zone question.
|
||||
newCleared := int(newProg)
|
||||
if newCleared > len(zoneOrder) {
|
||||
newCleared = len(zoneOrder)
|
||||
}
|
||||
for idx := s.ZonesCleared; idx < newCleared; idx++ {
|
||||
if !cleared[zoneOrder[idx]] {
|
||||
s.PendingMask = shadowSetBit(s.PendingMask, idx)
|
||||
}
|
||||
}
|
||||
|
||||
s.Progress = newProg
|
||||
s.ZonesCleared = newCleared
|
||||
s.DayCounter++
|
||||
s.LastAdvanced = today
|
||||
if err := upsertShadow(s); err != nil {
|
||||
slog.Warn("shadow: advance upsert", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shadowPlayerAgeDays estimates how many days the player has been adventuring,
|
||||
// clamped to at least 1 so a same-day character never divides by zero. Used as
|
||||
// the denominator of the player's pace.
|
||||
func shadowPlayerAgeDays(char *AdventureCharacter) float64 {
|
||||
if char.CreatedAt.IsZero() {
|
||||
return 1
|
||||
}
|
||||
days := time.Since(char.CreatedAt).Hours() / 24
|
||||
if days < 1 {
|
||||
return 1
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// shadowOnPlayerZoneClear is the payoff, fired when the player clears a zone's
|
||||
// boss. Three outcomes: the Shadow left a page here (pending bit) → grant the
|
||||
// page and clear the bit; the player beat the Shadow to this zone → a crow plus
|
||||
// a little bonus XP; or the Shadow already passed through with nothing owed →
|
||||
// nothing. Returns the narration line to append to the clear message ("" for
|
||||
// the silent case, or when the Shadow hasn't been born yet).
|
||||
func (p *AdventurePlugin) shadowOnPlayerZoneClear(userID id.UserID, zoneID ZoneID) string {
|
||||
s, err := loadShadow(userID)
|
||||
if err != nil || s == nil {
|
||||
return ""
|
||||
}
|
||||
idx := shadowZoneIndex(zoneID)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if shadowBitSet(s.PendingMask, idx) {
|
||||
// The Shadow got here first and left a page. Grant it BEFORE retiring the
|
||||
// debt: grantJournalPage returns "" both when the campaign is already
|
||||
// complete and on a transient DB error, so clear the pending bit only once
|
||||
// the page has actually landed (or the ledger is genuinely full). A
|
||||
// transient failure then leaves the bit set and the next clear retries,
|
||||
// rather than swallowing a page the player earned.
|
||||
pageLine := p.grantJournalPage(userID, nil)
|
||||
mask, _ := loadJournalPages(userID)
|
||||
if pageLine == "" && !journalComplete(mask) {
|
||||
slog.Warn("shadow: waiting page grant failed; leaving debt for retry",
|
||||
"user", userID, "zone", zoneID)
|
||||
return ""
|
||||
}
|
||||
s.PendingMask = shadowClearBit(s.PendingMask, idx)
|
||||
if err := upsertShadow(s); err != nil {
|
||||
// The page is already granted; a failed bit-clear can at worst re-grant
|
||||
// a different unfound page on a later re-clear, which is benign (pages
|
||||
// are collectible and the campaign self-limits). Better than losing it.
|
||||
slog.Warn("shadow: clear pending bit", "user", userID, "zone", zoneID, "err", err)
|
||||
}
|
||||
if pageLine == "" {
|
||||
// Ledger already complete — nothing to leave, but the Shadow was first.
|
||||
return shadowBeatYouHereLine(s)
|
||||
}
|
||||
return shadowLeftPageLine(s) + "\n" + pageLine
|
||||
}
|
||||
|
||||
// No page owed. If the Shadow hasn't reached this zone yet, the player beat
|
||||
// it here — crow and hand over a small bonus, but only once per zone: the
|
||||
// crowed bit makes re-running a not-yet-reached zone unable to farm the XP.
|
||||
if int(s.Progress) <= idx && !shadowBitSet(s.CrowedMask, idx) {
|
||||
xp := shadowCrowXP(zoneID)
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
// Don't promise XP we didn't grant, and don't burn the crow — leave the
|
||||
// bit unset so the next clear can crow for real.
|
||||
slog.Warn("shadow: crow xp", "user", userID, "err", err)
|
||||
return ""
|
||||
}
|
||||
s.CrowedMask = shadowSetBit(s.CrowedMask, idx)
|
||||
if err := upsertShadow(s); err != nil {
|
||||
slog.Warn("shadow: set crowed bit", "user", userID, "zone", zoneID, "err", err)
|
||||
}
|
||||
return shadowCrowLine(s, xp)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shadowCrowXP is the bonus for beating the Shadow to a zone, scaled by tier.
|
||||
func shadowCrowXP(zoneID ZoneID) int {
|
||||
tier := 1
|
||||
if z, ok := getZone(zoneID); ok {
|
||||
tier = int(z.Tier)
|
||||
}
|
||||
if tier < 1 {
|
||||
tier = 1
|
||||
}
|
||||
return tier * shadowCrowXPPerTier
|
||||
}
|
||||
|
||||
// shadowBriefingLine is the morning-briefing race-pressure beat: where the
|
||||
// Shadow stands relative to the player. Deterministic by day so a re-rendered
|
||||
// briefing reads the same, and silent until the Shadow has been born and has
|
||||
// something to say. TwinBee's voice.
|
||||
func (p *AdventurePlugin) shadowBriefingLine(e *Expedition) string {
|
||||
userID := id.UserID(e.UserID)
|
||||
s, err := loadShadow(userID)
|
||||
if err != nil || s == nil {
|
||||
return ""
|
||||
}
|
||||
playerCount := len(clearedZoneIDs(db.Get(), userID))
|
||||
lead := s.ZonesCleared - playerCount
|
||||
return shadowRacePressureLine(s, e.CurrentDay, lead)
|
||||
}
|
||||
|
||||
// ── Flavour ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// TwinBee's voice: first-person, implicit subject, he/him, one line. "The
|
||||
// Shadow"/"{name}" is the rival; "I"/"we" is TwinBee and the player. Never the
|
||||
// third-person "TwinBee" (guarded by test, same as the journal reactions).
|
||||
|
||||
// shadowAheadLines — TwinBee, the Shadow is ahead of us. Race pressure.
|
||||
var shadowAheadLines = []string{
|
||||
"Passed the Shadow's camp at dawn — cold ashes, a day old. %s is ahead of us again.",
|
||||
"Found %s's mark cut into a doorframe up ahead. Still warm. We're not gaining.",
|
||||
"%s came through here already. I can smell the pitch of a torch not long snuffed.",
|
||||
"The Shadow's been and gone. %s leaves the doors open behind, like he wants us to know.",
|
||||
}
|
||||
|
||||
// shadowBehindLines — TwinBee, we're ahead of the Shadow. Earned swagger.
|
||||
var shadowBehindLines = []string{
|
||||
"No sign of %s ahead — I think we're out in front for once. Let's keep it that way.",
|
||||
"We've outpaced the Shadow. %s is somewhere behind us, eating our dust. Good.",
|
||||
"Quiet up ahead. %s hasn't reached this deep yet. First ones through.",
|
||||
}
|
||||
|
||||
// shadowNeckLines — TwinBee, we're level with the Shadow. Tension.
|
||||
var shadowNeckLines = []string{
|
||||
"%s is right on our heels — or we're on his. Hard to say who's chasing who now.",
|
||||
"Neck and neck with the Shadow. I keep catching %s at the edge of the torchlight.",
|
||||
"%s is close. Same rooms, hours apart. Whoever clears the next zone first wins the day.",
|
||||
}
|
||||
|
||||
// shadowRacePressureLine picks a race-pressure line by the day and the Shadow's
|
||||
// lead, deterministically. lead > 0: Shadow ahead; lead < 0: player ahead; 0:
|
||||
// level.
|
||||
func shadowRacePressureLine(s *shadowState, day, lead int) string {
|
||||
var pool []string
|
||||
switch {
|
||||
case lead > 0:
|
||||
pool = shadowAheadLines
|
||||
case lead < 0:
|
||||
pool = shadowBehindLines
|
||||
default:
|
||||
pool = shadowNeckLines
|
||||
}
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
idx := (day + s.DayCounter) % len(pool)
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
return "👤 " + fmt.Sprintf(pool[idx], s.Name)
|
||||
}
|
||||
|
||||
// shadowLeftPageLine prefaces the waiting journal page: the Shadow cleared this
|
||||
// zone first and left a page behind for us. TwinBee's voice.
|
||||
func shadowLeftPageLine(s *shadowState) string {
|
||||
return fmt.Sprintf("👤 %s beat us to this one — but left a torn page pinned in the boss's lair, like a note for whoever came next.", s.Name)
|
||||
}
|
||||
|
||||
// shadowBeatYouHereLine is the page-less acknowledgement (campaign already
|
||||
// complete): the Shadow was first, but there's nothing left to find.
|
||||
func shadowBeatYouHereLine(s *shadowState) string {
|
||||
return fmt.Sprintf("👤 %s was here before us. Boot-prints in the dust, and nothing left to take. We know how this ends now anyway.", s.Name)
|
||||
}
|
||||
|
||||
// shadowCrowLine is TwinBee crowing that we beat the Shadow to a zone, with the
|
||||
// bonus XP.
|
||||
func shadowCrowLine(s *shadowState, xp int) string {
|
||||
return fmt.Sprintf("👤 First ones through — %s is still somewhere behind us. I'd crow if it wouldn't give us away. (**+%d XP**, for beating the Shadow here.)", s.Name, xp)
|
||||
}
|
||||
|
||||
// handleShadowCmd renders the player's standing against the Shadow. A quiet
|
||||
// status view — the feature lives mostly in the briefing and at zone clears.
|
||||
func (p *AdventurePlugin) handleShadowCmd(ctx MessageContext) error {
|
||||
s, err := loadShadow(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't read the trail just now. Try again in a moment.")
|
||||
}
|
||||
if s == nil {
|
||||
return p.SendDM(ctx.Sender, "👤 No one's shadowing you yet. Clear a zone or two and someone will take an interest.")
|
||||
}
|
||||
playerCount := len(clearedZoneIDs(db.Get(), ctx.Sender))
|
||||
total := len(zoneOrder)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("👤 **The Shadow — %s**\n\n", s.Name))
|
||||
b.WriteString(fmt.Sprintf("🏴 **%s's trail:** %d / %d zones cleared\n", s.Name, s.ZonesCleared, total))
|
||||
b.WriteString(fmt.Sprintf("🚩 **Your trail:** %d / %d zones cleared\n", playerCount, total))
|
||||
switch lead := s.ZonesCleared - playerCount; {
|
||||
case lead > 0:
|
||||
b.WriteString(fmt.Sprintf("\n_%s is **%d** ahead. Pages wait where he cleared first — take them back by clearing those zones yourself._", s.Name, lead))
|
||||
case lead < 0:
|
||||
b.WriteString(fmt.Sprintf("\n_You're **%d** ahead. Keep it that way._", -lead))
|
||||
default:
|
||||
b.WriteString("\n_Dead level. The next zone decides who's chasing whom._")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
360
internal/plugin/adventure_shadow_test.go
Normal file
360
internal/plugin/adventure_shadow_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newShadowTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// freshChar builds a same-day character so shadowPlayerAgeDays reads 1 day.
|
||||
func freshChar(user id.UserID) *AdventureCharacter {
|
||||
return &AdventureCharacter{
|
||||
UserID: user,
|
||||
DisplayName: "Tester",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowName_DeterministicAndInPool(t *testing.T) {
|
||||
user := id.UserID("@name:test.invalid")
|
||||
first := shadowNameFor("Aria", user)
|
||||
if again := shadowNameFor("Aria", user); again != first {
|
||||
t.Errorf("name not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
inPool := false
|
||||
for _, n := range shadowNames {
|
||||
if n == first {
|
||||
inPool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inPool {
|
||||
t.Errorf("generated name %q not in shadowNames pool", first)
|
||||
}
|
||||
// Empty display name falls back to the user id and still yields a pool name.
|
||||
if got := shadowNameFor("", user); got == "" {
|
||||
t.Error("empty display name produced an empty shadow name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadowFlavor_NoThirdPersonTwinBee: every Shadow line TwinBee speaks obeys
|
||||
// the campaign voice rule — he never refers to himself in the third person
|
||||
// (feedback_twinbee_voice). Same guard the journal reactions carry.
|
||||
func TestShadowFlavor_NoThirdPersonTwinBee(t *testing.T) {
|
||||
pools := [][]string{shadowAheadLines, shadowBehindLines, shadowNeckLines}
|
||||
for _, pool := range pools {
|
||||
for _, line := range pool {
|
||||
if strings.Contains(line, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee in Shadow pool line: %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "%s") {
|
||||
t.Errorf("race-pressure line missing the name slot: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
s := &shadowState{Name: "Vael"}
|
||||
rendered := []string{
|
||||
shadowLeftPageLine(s),
|
||||
shadowBeatYouHereLine(s),
|
||||
shadowCrowLine(s, 24),
|
||||
shadowRacePressureLine(s, 3, 1),
|
||||
shadowRacePressureLine(s, 3, -1),
|
||||
shadowRacePressureLine(s, 3, 0),
|
||||
}
|
||||
for _, r := range rendered {
|
||||
if strings.Contains(r, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee in rendered Shadow line: %q", r)
|
||||
}
|
||||
if !strings.Contains(r, "Vael") {
|
||||
t.Errorf("rendered line dropped the Shadow's name: %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowRacePressure_Deterministic(t *testing.T) {
|
||||
s := &shadowState{Name: "Vael", DayCounter: 2}
|
||||
first := shadowRacePressureLine(s, 4, 1)
|
||||
if again := shadowRacePressureLine(s, 4, 1); again != first {
|
||||
t.Errorf("race pressure not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
// Direction picks a different pool: ahead vs behind must differ in wording.
|
||||
ahead := shadowRacePressureLine(s, 4, 2)
|
||||
behind := shadowRacePressureLine(s, 4, -2)
|
||||
if ahead == behind {
|
||||
t.Errorf("ahead and behind produced the same line: %q", ahead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_CreepsAndIsIdempotent(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@creep:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, err := loadShadow(user)
|
||||
if err != nil || s == nil {
|
||||
t.Fatalf("shadow not born: %v", err)
|
||||
}
|
||||
if s.Progress <= 0 {
|
||||
t.Errorf("a born shadow should have crept forward, got progress %v", s.Progress)
|
||||
}
|
||||
if s.Name == "" {
|
||||
t.Error("born shadow has no name")
|
||||
}
|
||||
firstProg := s.Progress
|
||||
|
||||
// Second advance the same UTC day is a no-op.
|
||||
p.advanceShadow(char)
|
||||
s2, _ := loadShadow(user)
|
||||
if s2.Progress != firstProg {
|
||||
t.Errorf("same-day re-advance moved the shadow: %v -> %v", firstProg, s2.Progress)
|
||||
}
|
||||
|
||||
// Roll the clock back a day and it advances again.
|
||||
s2.LastAdvanced = time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
if err := upsertShadow(s2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.advanceShadow(char)
|
||||
s3, _ := loadShadow(user)
|
||||
if s3.Progress <= firstProg {
|
||||
t.Errorf("a new day should have advanced the shadow: %v -> %v", firstProg, s3.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_PendingBitForUnclearedZone(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@pending:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
// Seed the shadow one step short of clearing zone 0, dated yesterday so the
|
||||
// next advance runs. The player has cleared nothing.
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = 0.95
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.ZonesCleared < 1 {
|
||||
t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d prog=%v", s.ZonesCleared, s.Progress)
|
||||
}
|
||||
if !shadowBitSet(s.PendingMask, 0) {
|
||||
t.Errorf("shadow cleared zone 0 before the player but left no pending page (mask=%b)", s.PendingMask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_NoPendingWhenPlayerClearedFirst(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@nopending:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
// Player already cleared zone 0.
|
||||
insertClearedExpedition(t, user, zoneOrder[0], ExpeditionStatusComplete, 1, "{}")
|
||||
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = 0.95
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.ZonesCleared < 1 {
|
||||
t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d", s.ZonesCleared)
|
||||
}
|
||||
if shadowBitSet(s.PendingMask, 0) {
|
||||
t.Errorf("player cleared zone 0 first — no page should wait (mask=%b)", s.PendingMask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_LeadCapKeepsItARace(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@leadcap:test.invalid")
|
||||
char := freshChar(user) // player has cleared nothing → count 0
|
||||
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = shadowMaxLead // already at the cap for a 0-clear player
|
||||
seed.ZonesCleared = 2
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.Progress > shadowMaxLead+0.0001 {
|
||||
t.Errorf("shadow ran past the lead cap: progress %v > %v", s.Progress, shadowMaxLead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_PendingGrantsWaitingPage(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@page:test.invalid")
|
||||
|
||||
idx := 2
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 5
|
||||
seed.ZonesCleared = 5
|
||||
seed.PendingMask = shadowSetBit(0, idx)
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if strings.TrimSpace(line) == "" {
|
||||
t.Fatal("a waiting page should have produced a clear-message line")
|
||||
}
|
||||
if !strings.Contains(line, "page") {
|
||||
t.Errorf("expected a journal-page grant in the line, got: %q", line)
|
||||
}
|
||||
// Bit cleared so a re-clear can't re-award.
|
||||
s, _ := loadShadow(user)
|
||||
if shadowBitSet(s.PendingMask, idx) {
|
||||
t.Error("pending bit not cleared after the page was granted")
|
||||
}
|
||||
// The page actually landed.
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 {
|
||||
t.Errorf("expected exactly one journal page granted, got %d", journalPageCount(mask))
|
||||
}
|
||||
|
||||
// A second clear of the same zone grants nothing more.
|
||||
again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if again != "" {
|
||||
t.Errorf("re-clearing a paid-out zone should be silent, got: %q", again)
|
||||
}
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 {
|
||||
t.Errorf("re-clear granted a second page (count %d)", journalPageCount(mask))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_PlayerFirstCrows(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@crow:test.invalid")
|
||||
|
||||
idx := 3
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 0 // shadow hasn't reached this zone
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if !strings.Contains(line, "XP") {
|
||||
t.Errorf("beating the shadow should crow a bonus, got: %q", line)
|
||||
}
|
||||
// No page granted on a crow.
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 0 {
|
||||
t.Errorf("a crow should not grant a page (count %d)", journalPageCount(mask))
|
||||
}
|
||||
// The crowed bit is set so a re-run of the same not-yet-reached zone can't
|
||||
// farm the XP again — second clear is silent.
|
||||
if s, _ := loadShadow(user); !shadowBitSet(s.CrowedMask, idx) {
|
||||
t.Error("crowed bit not set after the first crow")
|
||||
}
|
||||
if again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); again != "" {
|
||||
t.Errorf("re-clearing an already-crowed zone should be silent, got: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_ShadowPassedNoDebt(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@passed:test.invalid")
|
||||
|
||||
idx := 1
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 5 // well past this zone
|
||||
seed.ZonesCleared = 5
|
||||
seed.PendingMask = 0 // nothing owed here
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); line != "" {
|
||||
t.Errorf("a zone the shadow passed with nothing owed should be silent, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_SilentBeforeBirth(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@unborn:test.invalid")
|
||||
if line := p.shadowOnPlayerZoneClear(user, zoneOrder[0]); line != "" {
|
||||
t.Errorf("no shadow yet should be silent, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowBriefingLine_SilentUntilBorn(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@brief:test.invalid")
|
||||
e := &Expedition{UserID: string(user), ZoneID: zoneOrder[0], CurrentDay: 2}
|
||||
|
||||
if line := p.shadowBriefingLine(e); line != "" {
|
||||
t.Errorf("no shadow should mean no briefing line, got: %q", line)
|
||||
}
|
||||
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.ZonesCleared = 2
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if line := p.shadowBriefingLine(e); strings.TrimSpace(line) == "" {
|
||||
t.Error("a born shadow should produce a briefing line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleShadowCmd_DMsStatus(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
sink := installSink(p)
|
||||
user := id.UserID("@cmd:test.invalid")
|
||||
|
||||
// Before birth: a gentle "no one's shadowing you yet".
|
||||
if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dms := sink.dmsTo(user); len(dms) != 1 || !strings.Contains(dms[0], "shadowing you yet") {
|
||||
t.Errorf("expected an unborn-shadow DM, got: %v", sink.dmsTo(user))
|
||||
}
|
||||
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.ZonesCleared = 4
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dms := sink.dmsTo(user)
|
||||
if len(dms) != 2 || !strings.Contains(dms[1], seed.Name) {
|
||||
t.Errorf("expected a status DM naming the shadow, got: %v", dms)
|
||||
}
|
||||
}
|
||||
@@ -1146,13 +1146,38 @@ func dailyCuriosStock() []MagicItem {
|
||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
|
||||
n := curiosStockSize
|
||||
if n > len(ids) {
|
||||
n = len(ids)
|
||||
target := curiosStockSize
|
||||
if target > len(ids) {
|
||||
target = len(ids)
|
||||
}
|
||||
stock := make([]MagicItem, 0, n)
|
||||
for _, id := range ids[:n] {
|
||||
stock := make([]MagicItem, 0, target)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// N7/E4 — a live season features its curated items at the front of the shelf,
|
||||
// displacing the day's rotation. They are existing registry items, so no
|
||||
// net-new power enters the economy for the week; only which items rotate in.
|
||||
if s, ok := activeSeason(); ok {
|
||||
for _, id := range s.CurioIDs {
|
||||
if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
|
||||
stock = append(stock, mi)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if len(stock) > target {
|
||||
target = len(stock)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the remaining slots from the day's deterministic rotation.
|
||||
for _, id := range ids {
|
||||
if len(stock) >= target {
|
||||
break
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
stock = append(stock, magicItemRegistry[id])
|
||||
seen[id] = true
|
||||
}
|
||||
// Stable display order: rarity ascending, then name.
|
||||
sort.Slice(stock, func(i, j int) bool {
|
||||
@@ -1167,7 +1192,11 @@ func dailyCuriosStock() []MagicItem {
|
||||
|
||||
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
|
||||
} else {
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
factor := p.shopSessionPriceFactor(userID)
|
||||
|
||||
@@ -40,7 +40,7 @@ func twinBeeMaxTier() int {
|
||||
if !c.Alive {
|
||||
continue
|
||||
}
|
||||
combined := dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
combined := combinedAdvLevel(&c)
|
||||
if combined > bestLevel {
|
||||
bestLevel = combined
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
weight := 4 // minimum (level 1 in all 4 skills)
|
||||
for _, c := range chars {
|
||||
if c.UserID == uid {
|
||||
weight = dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
weight = combinedAdvLevel(&c)
|
||||
if weight < 4 {
|
||||
weight = 4
|
||||
}
|
||||
|
||||
785
internal/plugin/adventure_worldboss.go
Normal file
785
internal/plugin/adventure_worldboss.go
Normal file
@@ -0,0 +1,785 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// World boss (N6/C3) — the monthly communal "Siege". A named boss camps outside
|
||||
// town for a fixed window with a single shared HP pool. Every player gets one
|
||||
// bout per day against it (an arena-style fight through runZoneCombat); the
|
||||
// damage they deal is subtracted from the shared pool whether the individual
|
||||
// bout is won or lost. The pool falling to zero defeats the boss; the window
|
||||
// lapsing with the pool still up means the boss survives and loots the town.
|
||||
//
|
||||
// Economy (decisions locked 2026-07-10): a defeat pays every contributor from a
|
||||
// freshly minted bounty scaled by fights fought (not damage — accessibility),
|
||||
// plus a consumable cache and one low-rate treasure roll each; a survival debits
|
||||
// the community pot as a tribute (a sink the economy wants). The bout itself
|
||||
// costs real HP the way the arena does — no restore — but never permadeaths.
|
||||
//
|
||||
// The event lives in its own tables (world_boss / world_boss_contrib), outside
|
||||
// the saveAdvCharacter fan-out, so a character save can never clobber the shared
|
||||
// pool — the same structural isolation adventure_shadow earns.
|
||||
|
||||
const (
|
||||
// worldBossWindow is how long the boss camps outside town once it spawns.
|
||||
worldBossWindow = 72 * time.Hour
|
||||
// worldBossPresenceDays is the any-chat presence lookback used to size the
|
||||
// boss to the community that will actually fight it
|
||||
// (feedback_presence_is_any_chat).
|
||||
worldBossPresenceDays = 14
|
||||
// worldBossMinTier floors the boss tier — the Siege is always a serious
|
||||
// fight, never a T1/T2 pushover, even for a low-level town.
|
||||
worldBossMinTier = 3
|
||||
// worldBossBoutsPerPlayer sizes the shared pool: expected full-damage bouts
|
||||
// to fell the boss ≈ activePlayers × this. Higher = a longer siege that needs
|
||||
// more of the town to turn up. Tunable; watch prod turnout.
|
||||
worldBossBoutsPerPlayer = 2.0
|
||||
// worldBossMinBouts / worldBossMaxBouts clamp the pool so a near-empty town
|
||||
// still gets a beatable boss and a huge town doesn't get an unkillable one.
|
||||
worldBossMinBouts = 4
|
||||
worldBossMaxBouts = 60
|
||||
// worldBossDefeatBaseEuro is the minted bounty a single fight earns a
|
||||
// contributor on a defeat; total payout = base × fights fought.
|
||||
worldBossDefeatBaseEuro = 1500
|
||||
// worldBossTributePct is the fraction of the community pot the boss loots on
|
||||
// a survival — a visible pot sink.
|
||||
worldBossTributePct = 0.20
|
||||
// worldBossTreasureWeight is the (low) weight of the single treasure roll each
|
||||
// contributor gets on a defeat.
|
||||
worldBossTreasureWeight = advTreasureWeightElite
|
||||
// worldBossCacheSize is how many tier consumables each contributor is handed
|
||||
// on a defeat.
|
||||
worldBossCacheSize = 2
|
||||
)
|
||||
|
||||
// worldBossState mirrors an active world_boss row.
|
||||
type worldBossState struct {
|
||||
ID int64
|
||||
Name string
|
||||
Tier int
|
||||
HPMax int
|
||||
HPCurrent int
|
||||
Status string // active | defeated | survived
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
}
|
||||
|
||||
// worldBossContrib mirrors a world_boss_contrib row — one player's tally against
|
||||
// one boss.
|
||||
type worldBossContrib struct {
|
||||
BossID int64
|
||||
UserID id.UserID
|
||||
Fights int
|
||||
Damage int
|
||||
LastFightDate string
|
||||
}
|
||||
|
||||
// worldBossNames is the pool a Siege boss is named from — big, ancient,
|
||||
// town-threatening things. Picked deterministically per event so a given month's
|
||||
// boss reads the same to everyone.
|
||||
var worldBossNames = []string{
|
||||
"Gorloth the Sunderer", "The Ashen Wyrm", "Kravok, Maw of the Deep",
|
||||
"Nyxaris the Devouring", "The Iron Colossus", "Ssath'ra, Coil of Ruin",
|
||||
"Ymirok the Frostbound", "The Hollow Leviathan", "Dravmaug, Ender of Walls",
|
||||
"The Bone Cathedral", "Vornath the Insatiable", "The Screaming Tide",
|
||||
}
|
||||
|
||||
// worldBossNameFor picks a stable boss name from an event key (the spawn month),
|
||||
// so the same Siege always reads by the same name.
|
||||
func worldBossNameFor(eventKey string) string {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(eventKey))
|
||||
return worldBossNames[int(h.Sum32())%len(worldBossNames)]
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
func scanWorldBoss(row interface{ Scan(...any) error }) (*worldBossState, error) {
|
||||
b := &worldBossState{}
|
||||
err := row.Scan(&b.ID, &b.Name, &b.Tier, &b.HPMax, &b.HPCurrent, &b.Status, &b.StartsAt, &b.EndsAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// loadActiveWorldBoss returns the live Siege, or (nil, nil) if none is camped.
|
||||
func loadActiveWorldBoss() (*worldBossState, error) {
|
||||
b, err := scanWorldBoss(db.Get().QueryRow(
|
||||
`SELECT id, name, tier, hp_max, hp_current, status, starts_at, ends_at
|
||||
FROM world_boss WHERE status = 'active' ORDER BY id DESC LIMIT 1`))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// loadWorldBoss reads one boss by id (history included). (nil, nil) if absent.
|
||||
func loadWorldBoss(bossID int64) (*worldBossState, error) {
|
||||
b, err := scanWorldBoss(db.Get().QueryRow(
|
||||
`SELECT id, name, tier, hp_max, hp_current, status, starts_at, ends_at
|
||||
FROM world_boss WHERE id = ?`, bossID))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// insertWorldBoss writes a fresh active boss and returns its id.
|
||||
func insertWorldBoss(name string, tier, hpMax int, startsAt, endsAt time.Time) (int64, error) {
|
||||
res, err := db.Get().Exec(
|
||||
`INSERT INTO world_boss (name, tier, hp_max, hp_current, status, starts_at, ends_at)
|
||||
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
|
||||
name, tier, hpMax, hpMax, startsAt, endsAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// applyWorldBossDamage subtracts damage from the shared pool atomically, clamped
|
||||
// at zero, only while the boss is still active. Returns the pool's new value and
|
||||
// whether the pool is now down (killed). The UPDATE and the re-read are separate
|
||||
// statements, so two concurrent killers can BOTH observe killed=true — that is
|
||||
// fine: resolveWorldBossDefeated dedupes on the status='active' guard in
|
||||
// setWorldBossStatus, so only one payout ever fires. killed is just "should the
|
||||
// caller attempt resolution".
|
||||
func applyWorldBossDamage(bossID int64, dmg int) (remaining int, killed bool, err error) {
|
||||
if dmg < 0 {
|
||||
dmg = 0
|
||||
}
|
||||
_, err = db.Get().Exec(
|
||||
`UPDATE world_boss SET hp_current = MAX(0, hp_current - ?)
|
||||
WHERE id = ? AND status = 'active'`, dmg, bossID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
b, err := loadWorldBoss(bossID)
|
||||
if err != nil || b == nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return b.HPCurrent, b.HPCurrent <= 0 && b.Status == "active", nil
|
||||
}
|
||||
|
||||
// setWorldBossStatus closes out a boss (defeated | survived) and stamps
|
||||
// resolved_at. Guarded on status='active' so a double resolution is a no-op.
|
||||
func setWorldBossStatus(bossID int64, status string) (bool, error) {
|
||||
res, err := db.Get().Exec(
|
||||
`UPDATE world_boss SET status = ?, resolved_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'active'`, status, bossID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// upsertWorldBossContrib bumps a player's tally against a boss.
|
||||
func upsertWorldBossContrib(bossID int64, userID id.UserID, dmg int, dateKey string) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO world_boss_contrib (boss_id, user_id, fights, damage, last_fight_date)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(boss_id, user_id) DO UPDATE SET
|
||||
fights = fights + 1,
|
||||
damage = damage + excluded.damage,
|
||||
last_fight_date = excluded.last_fight_date`,
|
||||
bossID, string(userID), dmg, dateKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadWorldBossContrib reads one player's tally, or (nil, nil) if they haven't
|
||||
// fought this boss.
|
||||
func loadWorldBossContrib(bossID int64, userID id.UserID) (*worldBossContrib, error) {
|
||||
c := &worldBossContrib{BossID: bossID, UserID: userID}
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT fights, damage, last_fight_date FROM world_boss_contrib
|
||||
WHERE boss_id = ? AND user_id = ?`, bossID, string(userID)).
|
||||
Scan(&c.Fights, &c.Damage, &c.LastFightDate)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// loadWorldBossContribs reads every contributor to a boss, ordered by fights
|
||||
// desc then damage desc (the board order).
|
||||
func loadWorldBossContribs(bossID int64) ([]worldBossContrib, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT user_id, fights, damage, last_fight_date FROM world_boss_contrib
|
||||
WHERE boss_id = ? ORDER BY fights DESC, damage DESC`, bossID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []worldBossContrib
|
||||
for rows.Next() {
|
||||
c := worldBossContrib{BossID: bossID}
|
||||
var uid string
|
||||
if err := rows.Scan(&uid, &c.Fights, &c.Damage, &c.LastFightDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.UserID = id.UserID(uid)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Scaling ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// activePlayerIDs returns the user ids that have chatted (any message, not just
|
||||
// commands) in the last `days` days — the any-chat presence signal
|
||||
// (feedback_presence_is_any_chat), read off the streaks plugin's daily_activity
|
||||
// table.
|
||||
func activePlayerIDs(days int) ([]id.UserID, error) {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT DISTINCT user_id FROM daily_activity WHERE date >= ?`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []id.UserID
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id.UserID(uid))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// combinedAdvLevel is the same "how strong is this adventurer" reducer TwinBee
|
||||
// uses — combat level plus the three gathering skills.
|
||||
func combinedAdvLevel(c *AdventureCharacter) int {
|
||||
return dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
}
|
||||
|
||||
// medianInt returns the median of a sorted-or-not int slice (0 for empty).
|
||||
func medianInt(xs []int) int {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := append([]int(nil), xs...)
|
||||
sort.Ints(s)
|
||||
mid := len(s) / 2
|
||||
if len(s)%2 == 1 {
|
||||
return s[mid]
|
||||
}
|
||||
return (s[mid-1] + s[mid]) / 2
|
||||
}
|
||||
|
||||
// tierForCombinedLevel buckets a combined adventure level to a zone tier, floored
|
||||
// at worldBossMinTier. Same thresholds as twinBeeMaxTier (combined 12 → T4,
|
||||
// 21 → T5), applied here to the median rather than the max.
|
||||
func tierForCombinedLevel(level int) int {
|
||||
switch {
|
||||
case level >= 21:
|
||||
return 5
|
||||
case level >= 12:
|
||||
return 4
|
||||
default:
|
||||
return worldBossMinTier
|
||||
}
|
||||
}
|
||||
|
||||
// groupInt renders an int with thousands separators (no currency symbol),
|
||||
// reusing fmtEuro's grouping.
|
||||
func groupInt(n int) string {
|
||||
return strings.TrimPrefix(fmtEuro(n), "€")
|
||||
}
|
||||
|
||||
// worldBossSpawnPlan computes the boss's tier and pool HP from the players active
|
||||
// in the presence window. Returns the tier, HP, and the active-player count that
|
||||
// drove the sizing (0 players ⇒ a floor boss so a manual spawn still works).
|
||||
func worldBossSpawnPlan() (tier, hpMax, activeN int) {
|
||||
ids, err := activePlayerIDs(worldBossPresenceDays)
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: active player lookup failed", "err", err)
|
||||
}
|
||||
active := make(map[id.UserID]bool, len(ids))
|
||||
for _, uid := range ids {
|
||||
active[uid] = true
|
||||
}
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: char load failed", "err", err)
|
||||
}
|
||||
var levels []int
|
||||
for i := range chars {
|
||||
c := &chars[i]
|
||||
if !active[c.UserID] || !c.Alive {
|
||||
continue
|
||||
}
|
||||
levels = append(levels, combinedAdvLevel(c))
|
||||
}
|
||||
activeN = len(levels)
|
||||
tier = tierForCombinedLevel(medianInt(levels))
|
||||
|
||||
bouts := int(float64(max(activeN, 1)) * worldBossBoutsPerPlayer)
|
||||
if bouts < worldBossMinBouts {
|
||||
bouts = worldBossMinBouts
|
||||
}
|
||||
if bouts > worldBossMaxBouts {
|
||||
bouts = worldBossMaxBouts
|
||||
}
|
||||
perBout, _, _, _, _ := arenaTierBaseStats(tier)
|
||||
hpMax = perBout * bouts
|
||||
return tier, hpMax, activeN
|
||||
}
|
||||
|
||||
// worldBossTemplate builds the disposable per-bout enemy stat block. The shared
|
||||
// pool lives in world_boss.hp_current; this template is just the thing a single
|
||||
// player swings at, so its own HP is the arena tier's boss HP — beatable in a
|
||||
// bout, which lets a strong player land a full boss's worth of damage on the
|
||||
// pool.
|
||||
func worldBossTemplate(name string, tier int) DnDMonsterTemplate {
|
||||
hp, ac, atk, ab, cr := arenaTierBaseStats(tier)
|
||||
return DnDMonsterTemplate{
|
||||
ID: fmt.Sprintf("worldboss_t%d", tier),
|
||||
Name: name,
|
||||
CR: cr,
|
||||
HP: hp,
|
||||
AC: ac,
|
||||
Attack: atk,
|
||||
AttackBonus: ab,
|
||||
Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
XPValue: arenaTiers[tier-1].BattleXP * 5,
|
||||
Notes: "the Siege",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
// spawnWorldBoss mints a fresh Siege and announces it. It refuses if one is
|
||||
// already camped (only one at a time). eventKey names the boss deterministically
|
||||
// (the spawn month for the auto path, a stamp for a manual one).
|
||||
func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, error) {
|
||||
if existing, err := loadActiveWorldBoss(); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil {
|
||||
return existing, fmt.Errorf("a world boss is already active")
|
||||
}
|
||||
tier, hpMax, activeN := worldBossSpawnPlan()
|
||||
name := worldBossNameFor(eventKey)
|
||||
now := time.Now().UTC()
|
||||
ends := now.Add(worldBossWindow)
|
||||
bossID, err := insertWorldBoss(name, tier, hpMax, now, ends)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boss, err := loadWorldBoss(bossID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.announceWorldBossSpawn(boss, activeN)
|
||||
slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN)
|
||||
return boss, nil
|
||||
}
|
||||
|
||||
// worldBossTick rides the 1-minute event ticker. It auto-spawns a boss on the
|
||||
// first of each UTC month and resolves a live boss whose window has lapsed. The
|
||||
// defeat path is not here — a bout crossing the pool to zero resolves inline
|
||||
// (W2), because the ticker never sees the pool between two 60s reads.
|
||||
func (p *AdventurePlugin) worldBossTick() {
|
||||
boss, err := loadActiveWorldBoss()
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: tick load failed", "err", err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if boss != nil {
|
||||
// Safety net: a killing blow commits the pool to 0 inline, but if the
|
||||
// process died (or was redeployed) before the bout resolved the status,
|
||||
// the boss is left active/0-HP. Resolve it as the defeat it was — never
|
||||
// let the survive branch below debit the pot for a boss the town killed.
|
||||
if boss.HPCurrent <= 0 {
|
||||
p.resolveWorldBossDefeated(boss)
|
||||
return
|
||||
}
|
||||
if now.After(boss.EndsAt) {
|
||||
p.resolveWorldBossSurvived(boss)
|
||||
}
|
||||
return
|
||||
}
|
||||
// No boss camped — auto-spawn on the 1st of the month, once.
|
||||
if now.Day() != 1 {
|
||||
return
|
||||
}
|
||||
monthKey := now.Format("2006-01")
|
||||
if db.JobCompleted("worldboss_spawn", monthKey) {
|
||||
return
|
||||
}
|
||||
if _, err := p.spawnWorldBoss(monthKey); err != nil {
|
||||
slog.Warn("worldboss: auto-spawn failed", "err", err)
|
||||
return
|
||||
}
|
||||
db.MarkJobCompleted("worldboss_spawn", monthKey)
|
||||
}
|
||||
|
||||
// ── Resolution ───────────────────────────────────────────────────────────────
|
||||
|
||||
// worldBossPayout is one contributor's minted defeat reward.
|
||||
type worldBossPayout struct {
|
||||
UserID id.UserID
|
||||
Fights int
|
||||
Euro int
|
||||
}
|
||||
|
||||
// computeWorldBossPayouts scales the minted bounty by fights fought (not damage —
|
||||
// accessibility). Pure so the split is testable without a euro/Matrix stub.
|
||||
func computeWorldBossPayouts(contribs []worldBossContrib, baseEuro int) []worldBossPayout {
|
||||
out := make([]worldBossPayout, 0, len(contribs))
|
||||
for _, c := range contribs {
|
||||
if c.Fights <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, worldBossPayout{UserID: c.UserID, Fights: c.Fights, Euro: baseEuro * c.Fights})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveWorldBossDefeated pays every contributor a minted bounty scaled by
|
||||
// fights, plus a consumable cache and one low-rate treasure roll each, then
|
||||
// announces the kill. Guarded so it fires once.
|
||||
func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) {
|
||||
won, err := setWorldBossStatus(boss.ID, "defeated")
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: defeat close-out failed", "err", err)
|
||||
return
|
||||
}
|
||||
if !won {
|
||||
return // someone else already resolved it
|
||||
}
|
||||
contribs, err := loadWorldBossContribs(boss.ID)
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: contrib load failed", "err", err)
|
||||
}
|
||||
payouts := computeWorldBossPayouts(contribs, worldBossDefeatBaseEuro)
|
||||
treasureZone := worldBossTreasureZone(boss.Tier)
|
||||
for _, pay := range payouts {
|
||||
p.euro.Credit(pay.UserID, float64(pay.Euro), "worldboss_bounty")
|
||||
for _, item := range consumableCache(boss.Tier, worldBossCacheSize) {
|
||||
it := item
|
||||
p.grantZoneItem(pay.UserID, &it, "🧪")
|
||||
}
|
||||
if treasureZone != "" {
|
||||
p.rollZoneTreasure(pay.UserID, treasureZone, worldBossTreasureWeight)
|
||||
}
|
||||
}
|
||||
p.announceWorldBossDefeated(boss, payouts)
|
||||
slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts))
|
||||
}
|
||||
|
||||
// resolveWorldBossSurvived debits the community pot as a tribute (a sink) and
|
||||
// announces the town's loss. Guarded so it fires once.
|
||||
func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) {
|
||||
won, err := setWorldBossStatus(boss.ID, "survived")
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: survive close-out failed", "err", err)
|
||||
return
|
||||
}
|
||||
if !won {
|
||||
return
|
||||
}
|
||||
tribute := int(float64(communityPotBalance()) * worldBossTributePct)
|
||||
paid := 0
|
||||
if tribute > 0 && communityPotDebit(tribute) {
|
||||
paid = tribute
|
||||
}
|
||||
p.announceWorldBossSurvived(boss, paid)
|
||||
slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid)
|
||||
}
|
||||
|
||||
// worldBossTreasureZone maps the boss tier to a representative zone whose treasure
|
||||
// table the defeat roll draws from. "" if no zone of that tier exists.
|
||||
func worldBossTreasureZone(tier int) ZoneID {
|
||||
zs := zonesByTier(ZoneTier(tier))
|
||||
if len(zs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return zs[0].ID
|
||||
}
|
||||
|
||||
// ── Announcements (games room) ───────────────────────────────────────────────
|
||||
|
||||
// announceWorldBoss posts to the shared games room. No-op if GAMES_ROOM is unset.
|
||||
func (p *AdventurePlugin) announceWorldBoss(text string) {
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
if err := p.SendMessage(gr, text); err != nil {
|
||||
slog.Warn("worldboss: games-room announce failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceWorldBossSpawn(boss *worldBossState, activeN int) {
|
||||
p.announceWorldBoss(fmt.Sprintf(
|
||||
"⚔️ **The Siege has begun!** **%s** (Tier %d) camps outside town with **%s HP**.\n"+
|
||||
"Everyone gets **one bout per day** for the next 72 hours — `!adventure worldboss fight`. "+
|
||||
"Every hit chips the shared pool. Fell it together for a bounty; let it stand and it loots the town.",
|
||||
boss.Name, boss.Tier, groupInt(boss.HPMax)))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceWorldBossDefeated(boss *worldBossState, payouts []worldBossPayout) {
|
||||
var top string
|
||||
if len(payouts) > 0 {
|
||||
top = fmt.Sprintf(" Top of the muster: %s (%d fights).",
|
||||
p.DisplayName(payouts[0].UserID), payouts[0].Fights)
|
||||
}
|
||||
p.announceWorldBoss(fmt.Sprintf(
|
||||
"🏆 **%s has fallen!** %d adventurer(s) brought it down.%s\n"+
|
||||
"Bounties, supply caches, and salvage have been paid out to everyone who fought.",
|
||||
boss.Name, len(payouts), top))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceWorldBossSurvived(boss *worldBossState, tribute int) {
|
||||
msg := fmt.Sprintf("💀 **%s still stands.** The Siege is over — the town could not bring it down.", boss.Name)
|
||||
if tribute > 0 {
|
||||
msg += fmt.Sprintf(" It loots **€%s** from the community pot on its way out.", groupInt(tribute))
|
||||
}
|
||||
p.announceWorldBoss(msg)
|
||||
}
|
||||
|
||||
// ── Player command (W2) ──────────────────────────────────────────────────────
|
||||
|
||||
// handleWorldBossCmd routes `!adventure worldboss [status|fight|spawn]` (alias
|
||||
// `!adventure siege …`). Bare / "status" shows the board; "fight" takes today's
|
||||
// bout; "spawn" is an operator override.
|
||||
func (p *AdventurePlugin) handleWorldBossCmd(ctx MessageContext, args string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(args)) {
|
||||
case "", "status":
|
||||
return p.worldBossStatusDM(ctx)
|
||||
case "fight":
|
||||
return p.fightWorldBoss(ctx)
|
||||
case "spawn":
|
||||
return p.worldBossOperatorSpawn(ctx)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Usage: `!adventure worldboss [status|fight]`")
|
||||
}
|
||||
|
||||
// worldBossOperatorSpawn lets an admin start a Siege on demand (the override half
|
||||
// of the "both" spawn decision). Silent to non-admins, like the other admin
|
||||
// commands.
|
||||
func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error {
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
key := "manual-" + time.Now().UTC().Format("2006-01-02T15:04")
|
||||
boss, err := p.spawnWorldBoss(key)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Could not spawn the Siege: %v", err))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Spawned **%s** (Tier %d, %s HP). It camps for 72 hours.",
|
||||
boss.Name, boss.Tier, groupInt(boss.HPMax)))
|
||||
}
|
||||
|
||||
// fightWorldBoss runs one player's daily bout against the Siege: an arena-style
|
||||
// solo fight whose damage is subtracted from the shared pool win or lose. Real
|
||||
// HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but
|
||||
// standing. The per-user lock serialises a player's own repeat submits, so the
|
||||
// once-per-day gate can't be raced by a double-tap.
|
||||
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
boss, err := loadActiveWorldBoss()
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
|
||||
}
|
||||
if boss == nil {
|
||||
return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.")
|
||||
}
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil || char == nil {
|
||||
return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.")
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
|
||||
}
|
||||
|
||||
bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today)
|
||||
if err != nil {
|
||||
slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.")
|
||||
}
|
||||
|
||||
// Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is
|
||||
// already committed to 0; flipping the status now closes the window where a
|
||||
// crash or the ticker's survive path could resolve a killed boss as a
|
||||
// survival and debit the pot instead of paying bounties.
|
||||
if bout.Killed {
|
||||
p.resolveWorldBossDefeated(boss)
|
||||
}
|
||||
|
||||
playerName, _ := loadDisplayName(ctx.Sender)
|
||||
if playerName == "" {
|
||||
playerName = "You"
|
||||
}
|
||||
phaseMessages := append([]string{
|
||||
fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier),
|
||||
}, RenderCombatLog(bout.Combat, playerName, boss.Name)...)
|
||||
|
||||
var footer string
|
||||
if bout.Killed {
|
||||
footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name)
|
||||
} else {
|
||||
footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.",
|
||||
bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax))
|
||||
}
|
||||
if bout.Battered {
|
||||
footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing."
|
||||
}
|
||||
|
||||
<-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// worldBossBoutUsedToday reports whether a player has already spent their daily
|
||||
// bout against this boss.
|
||||
func worldBossBoutUsedToday(bossID int64, userID id.UserID, dateKey string) bool {
|
||||
c, err := loadWorldBossContrib(bossID, userID)
|
||||
return err == nil && c != nil && c.LastFightDate == dateKey
|
||||
}
|
||||
|
||||
// worldBossBoutResult is the outcome of one resolved bout, before narration.
|
||||
type worldBossBoutResult struct {
|
||||
Damage int
|
||||
Remaining int
|
||||
Killed bool
|
||||
Battered bool
|
||||
Combat CombatResult
|
||||
}
|
||||
|
||||
// resolveWorldBossBout runs the arena-style fight, applies the real HP cost with
|
||||
// the no-death floor, subtracts the damage dealt from the shared pool, and
|
||||
// records the contribution. It performs no DM or games-room I/O, so it is the
|
||||
// testable core of fightWorldBoss.
|
||||
func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBossState, dateKey string) (worldBossBoutResult, error) {
|
||||
monster := worldBossTemplate(boss.Name, boss.Tier)
|
||||
result, err := p.runZoneCombat(userID, monster, boss.Tier, bossCombatPhases, 50)
|
||||
if err != nil {
|
||||
return worldBossBoutResult{}, err
|
||||
}
|
||||
|
||||
// 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 := floorHPAtOne(userID)
|
||||
|
||||
dmg := result.EnemyEntryHP - result.EnemyEndHP
|
||||
if dmg < 0 {
|
||||
dmg = 0
|
||||
}
|
||||
// Record the contribution (which also sets the once-per-day gate) BEFORE
|
||||
// draining the shared pool. If this write fails we must not have already
|
||||
// drained the pool — otherwise the player could refight a pool they emptied
|
||||
// and lose the credit for it. A hard error here aborts the bout; the HP cost
|
||||
// was already paid, but the pool is untouched and the player can retry.
|
||||
if err := upsertWorldBossContrib(boss.ID, userID, dmg, dateKey); err != nil {
|
||||
return worldBossBoutResult{}, err
|
||||
}
|
||||
remaining, killed, err := applyWorldBossDamage(boss.ID, dmg)
|
||||
if err != nil {
|
||||
return worldBossBoutResult{}, err
|
||||
}
|
||||
return worldBossBoutResult{
|
||||
Damage: dmg, Remaining: remaining, Killed: killed, Battered: battered, Combat: result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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. 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
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_character SET hp_current = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
|
||||
string(userID)); err != nil {
|
||||
slog.Warn("worldboss: hp floor failed", "user", userID, "err", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// worldBossStatusDM renders the current Siege board to the caller.
|
||||
func (p *AdventurePlugin) worldBossStatusDM(ctx MessageContext) error {
|
||||
boss, err := loadActiveWorldBoss()
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
|
||||
}
|
||||
if boss == nil {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"No Siege is camped outside town right now. The next one arrives at the start of the month.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
pct := 0
|
||||
if boss.HPMax > 0 {
|
||||
pct = boss.HPCurrent * 100 / boss.HPMax
|
||||
}
|
||||
fmt.Fprintf(&sb, "⚔️ **The Siege — %s** (Tier %d)\n", boss.Name, boss.Tier)
|
||||
fmt.Fprintf(&sb, "Pool: **%s / %s HP** (%d%%)\n", groupInt(boss.HPCurrent), groupInt(boss.HPMax), pct)
|
||||
if left := time.Until(boss.EndsAt); left > 0 {
|
||||
h := int(left.Hours())
|
||||
m := int(left.Minutes()) % 60
|
||||
fmt.Fprintf(&sb, "Time left: **%dh %dm**\n", h, m)
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if c, _ := loadWorldBossContrib(boss.ID, ctx.Sender); c != nil {
|
||||
line := fmt.Sprintf("\nYou: %d fight(s), %s damage dealt.", c.Fights, groupInt(c.Damage))
|
||||
if c.LastFightDate == today {
|
||||
line += " Your bout is spent for today."
|
||||
} else {
|
||||
line += " Your bout is ready — `!adventure worldboss fight`."
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
} else {
|
||||
sb.WriteString("\nYou haven't fought yet — `!adventure worldboss fight`.\n")
|
||||
}
|
||||
|
||||
if contribs, _ := loadWorldBossContribs(boss.ID); len(contribs) > 0 {
|
||||
sb.WriteString("\n**Top of the muster:**\n")
|
||||
for i, c := range contribs {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&sb, "%d. %s — %d fight(s)\n", i+1, p.DisplayName(c.UserID), c.Fights)
|
||||
}
|
||||
}
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
417
internal/plugin/adventure_worldboss_test.go
Normal file
417
internal/plugin/adventure_worldboss_test.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newWorldBossTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func TestWorldBossName_DeterministicAndInPool(t *testing.T) {
|
||||
first := worldBossNameFor("2026-07")
|
||||
if again := worldBossNameFor("2026-07"); again != first {
|
||||
t.Errorf("name not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
inPool := false
|
||||
for _, n := range worldBossNames {
|
||||
if n == first {
|
||||
inPool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inPool {
|
||||
t.Errorf("generated name %q not in worldBossNames pool", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedianInt(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []int
|
||||
want int
|
||||
}{
|
||||
{nil, 0},
|
||||
{[]int{5}, 5},
|
||||
{[]int{3, 1, 2}, 2}, // odd, unsorted
|
||||
{[]int{4, 2, 8, 6}, 5}, // even → mean of the two middles (4,6)
|
||||
{[]int{10, 10, 10, 10}, 10}, // even, all equal
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := medianInt(c.in); got != c.want {
|
||||
t.Errorf("medianInt(%v) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTierForCombinedLevel(t *testing.T) {
|
||||
cases := []struct{ level, want int }{
|
||||
{0, worldBossMinTier},
|
||||
{11, worldBossMinTier},
|
||||
{12, 4},
|
||||
{20, 4},
|
||||
{21, 5},
|
||||
{99, 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := tierForCombinedLevel(c.level); got != c.want {
|
||||
t.Errorf("tierForCombinedLevel(%d) = %d, want %d", c.level, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorldBoss_InsertLoadRoundTrip(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
id0, err := insertWorldBoss("Gorloth", 5, 2400, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := loadActiveWorldBoss()
|
||||
if err != nil || active == nil {
|
||||
t.Fatalf("loadActiveWorldBoss: %v (nil=%v)", err, active == nil)
|
||||
}
|
||||
if active.ID != id0 || active.Name != "Gorloth" || active.Tier != 5 ||
|
||||
active.HPMax != 2400 || active.HPCurrent != 2400 || active.Status != "active" {
|
||||
t.Errorf("round-trip mismatch: %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWorldBossDamage_ClampsAndFells(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
bossID, err := insertWorldBoss("Kravok", 3, 100, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rem, killed, err := applyWorldBossDamage(bossID, 40)
|
||||
if err != nil || rem != 60 || killed {
|
||||
t.Fatalf("first hit: rem=%d killed=%v err=%v (want 60,false)", rem, killed, err)
|
||||
}
|
||||
|
||||
// Overkill clamps at 0 and reports the fell.
|
||||
rem, killed, err = applyWorldBossDamage(bossID, 999)
|
||||
if err != nil || rem != 0 || !killed {
|
||||
t.Fatalf("overkill: rem=%d killed=%v err=%v (want 0,true)", rem, killed, err)
|
||||
}
|
||||
|
||||
// Negative damage is treated as zero.
|
||||
if rem, _, err := applyWorldBossDamage(bossID, -5); err != nil || rem != 0 {
|
||||
t.Fatalf("negative: rem=%d err=%v", rem, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetWorldBossStatus_ResolvesOnce(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
bossID, err := insertWorldBoss("Ymirok", 4, 500, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := setWorldBossStatus(bossID, "defeated"); err != nil || !ok {
|
||||
t.Fatalf("first close-out: ok=%v err=%v (want true)", ok, err)
|
||||
}
|
||||
// A second close-out is a no-op — the boss is no longer active.
|
||||
if ok, err := setWorldBossStatus(bossID, "survived"); err != nil || ok {
|
||||
t.Fatalf("double close-out: ok=%v err=%v (want false)", ok, err)
|
||||
}
|
||||
// And damage no longer lands on a resolved boss.
|
||||
if rem, killed, _ := applyWorldBossDamage(bossID, 500); rem != 500 || killed {
|
||||
t.Errorf("damage on resolved boss changed pool: rem=%d killed=%v", rem, killed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorldBossContrib_UpsertAccumulates(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
user := id.UserID("@a:test.invalid")
|
||||
if err := upsertWorldBossContrib(1, user, 30, "2026-07-01"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertWorldBossContrib(1, user, 45, "2026-07-02"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := loadWorldBossContrib(1, user)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v (nil=%v)", err, c == nil)
|
||||
}
|
||||
if c.Fights != 2 || c.Damage != 75 || c.LastFightDate != "2026-07-02" {
|
||||
t.Errorf("accumulate mismatch: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorldBossContribs_OrderedByFights(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
a := id.UserID("@a:test.invalid")
|
||||
b := id.UserID("@b:test.invalid")
|
||||
// a: 1 fight; b: 2 fights → b sorts first.
|
||||
upsertWorldBossContrib(7, a, 100, "2026-07-01")
|
||||
upsertWorldBossContrib(7, b, 10, "2026-07-01")
|
||||
upsertWorldBossContrib(7, b, 10, "2026-07-02")
|
||||
got, err := loadWorldBossContribs(7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].UserID != b || got[1].UserID != a {
|
||||
t.Errorf("order = %+v, want b then a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeWorldBossPayouts_ScalesByFights(t *testing.T) {
|
||||
contribs := []worldBossContrib{
|
||||
{UserID: "@a:t", Fights: 3, Damage: 999},
|
||||
{UserID: "@b:t", Fights: 1, Damage: 10},
|
||||
{UserID: "@c:t", Fights: 0, Damage: 0}, // no fights → no payout
|
||||
}
|
||||
pays := computeWorldBossPayouts(contribs, 1000)
|
||||
if len(pays) != 2 {
|
||||
t.Fatalf("got %d payouts, want 2 (zero-fight excluded)", len(pays))
|
||||
}
|
||||
if pays[0].Euro != 3000 || pays[1].Euro != 1000 {
|
||||
t.Errorf("payouts = %d,%d want 3000,1000", pays[0].Euro, pays[1].Euro)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldBossSpawnPlan_SizesToActiveTown seeds three active max-level players
|
||||
// and checks the boss is T5 with a pool scaled to the turnout.
|
||||
func TestWorldBossSpawnPlan_SizesToActiveTown(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
for _, u := range []string{"@a:test.invalid", "@b:test.invalid", "@c:test.invalid"} {
|
||||
c := &AdventureCharacter{UserID: id.UserID(u), DisplayName: "P", Alive: true,
|
||||
ForagingSkill: 30, CreatedAt: time.Now().UTC()}
|
||||
if err := saveAdvCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)`,
|
||||
u, today); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tier, hpMax, activeN := worldBossSpawnPlan()
|
||||
if activeN != 3 {
|
||||
t.Errorf("activeN = %d, want 3", activeN)
|
||||
}
|
||||
if tier != 5 {
|
||||
t.Errorf("tier = %d, want 5 (combined >= 21)", tier)
|
||||
}
|
||||
// bouts = 3 × 2.0 = 6; perBout at T5 = 400 → pool 2400.
|
||||
perBout, _, _, _, _ := arenaTierBaseStats(5)
|
||||
if want := perBout * 6; hpMax != want {
|
||||
t.Errorf("hpMax = %d, want %d", hpMax, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss: no active players still yields a
|
||||
// beatable floor boss so a manual spawn works.
|
||||
func TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
tier, hpMax, activeN := worldBossSpawnPlan()
|
||||
if activeN != 0 {
|
||||
t.Errorf("activeN = %d, want 0", activeN)
|
||||
}
|
||||
if tier != worldBossMinTier {
|
||||
t.Errorf("tier = %d, want floor %d", tier, worldBossMinTier)
|
||||
}
|
||||
perBout, _, _, _, _ := arenaTierBaseStats(worldBossMinTier)
|
||||
if want := perBout * worldBossMinBouts; hpMax != want {
|
||||
t.Errorf("hpMax = %d, want %d (min bouts floor)", hpMax, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnWorldBoss_RefusesWhenOneIsActive(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
first, err := p.spawnWorldBoss("2026-07")
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("first spawn: %v (nil=%v)", err, first == nil)
|
||||
}
|
||||
second, err := p.spawnWorldBoss("2026-07")
|
||||
if err == nil {
|
||||
t.Error("second spawn should refuse while one is active")
|
||||
}
|
||||
if second == nil || second.ID != first.ID {
|
||||
t.Error("refusal should return the existing active boss")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveWorldBossSurvived_DebitsPot exercises the survive path end to end:
|
||||
// the boss closes out and the pot loses its tribute.
|
||||
func TestResolveWorldBossSurvived_DebitsPot(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
communityPotAdd(1000)
|
||||
now := time.Now().UTC()
|
||||
bossID, err := insertWorldBoss("Vornath", 5, 2400, now.Add(-worldBossWindow), now.Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boss, _ := loadWorldBoss(bossID)
|
||||
p := &AdventurePlugin{}
|
||||
p.resolveWorldBossSurvived(boss)
|
||||
|
||||
after, _ := loadWorldBoss(bossID)
|
||||
if after.Status != "survived" {
|
||||
t.Errorf("status = %q, want survived", after.Status)
|
||||
}
|
||||
// tribute = 20% of 1000 = 200 → pot left with 800.
|
||||
if bal := communityPotBalance(); bal != 800 {
|
||||
t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// ── W2: the bout ─────────────────────────────────────────────────────────────
|
||||
|
||||
func fightableChar(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, "boxer"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 12,
|
||||
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorldBossFloorHP(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
uid := id.UserID("@floor:test.invalid")
|
||||
fightableChar(t, 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 !floorHPAtOne(uid) {
|
||||
t.Error("floor should raise a 0-HP fighter")
|
||||
}
|
||||
if cur, _ := dndHPSnapshot(uid); cur != 1 {
|
||||
t.Errorf("hp after floor = %d, want 1", cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorldBossBout_SubtractsAndRecords(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
uid := id.UserID("@bout:test.invalid")
|
||||
fightableChar(t, uid)
|
||||
now := time.Now().UTC()
|
||||
// Big pool so one bout can only chip it — assert the partial subtract + contrib.
|
||||
bossID, err := insertWorldBoss("Kravok", 3, 100000, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boss, _ := loadWorldBoss(bossID)
|
||||
p := &AdventurePlugin{}
|
||||
bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
|
||||
if err != nil {
|
||||
t.Fatalf("bout: %v", err)
|
||||
}
|
||||
if bout.Damage < 0 {
|
||||
t.Errorf("damage negative: %d", bout.Damage)
|
||||
}
|
||||
if bout.Killed {
|
||||
t.Error("a 100k pool should survive one bout")
|
||||
}
|
||||
if bout.Remaining != 100000-bout.Damage {
|
||||
t.Errorf("remaining %d != max-dmg %d", bout.Remaining, 100000-bout.Damage)
|
||||
}
|
||||
c, _ := loadWorldBossContrib(bossID, uid)
|
||||
if c == nil || c.Fights != 1 || c.Damage != bout.Damage || c.LastFightDate != "2026-07-05" {
|
||||
t.Errorf("contrib mismatch: %+v (bout dmg %d)", c, bout.Damage)
|
||||
}
|
||||
if !worldBossBoutUsedToday(bossID, uid, "2026-07-05") {
|
||||
t.Error("bout should read as used for that day")
|
||||
}
|
||||
if worldBossBoutUsedToday(bossID, uid, "2026-07-06") {
|
||||
t.Error("a different day should still be available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorldBossBout_FellsTinyPool(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
uid := id.UserID("@kill:test.invalid")
|
||||
fightableChar(t, uid)
|
||||
now := time.Now().UTC()
|
||||
bossID, err := insertWorldBoss("Wisp", 3, 1, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boss, _ := loadWorldBoss(bossID)
|
||||
p := &AdventurePlugin{}
|
||||
bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
|
||||
if err != nil {
|
||||
t.Fatalf("bout: %v", err)
|
||||
}
|
||||
// A L12 fighter lands at least one blow on a T3 dummy over a full boss fight,
|
||||
// felling a 1-HP pool.
|
||||
if !bout.Killed || bout.Remaining != 0 {
|
||||
t.Errorf("tiny pool not felled: killed=%v remaining=%d dmg=%d", bout.Killed, bout.Remaining, bout.Damage)
|
||||
}
|
||||
// Resolution is the caller's job — the bout leaves the boss active.
|
||||
after, _ := loadWorldBoss(bossID)
|
||||
if after.Status != "active" {
|
||||
t.Errorf("bout should not self-resolve; status=%q", after.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldBossTick_ResolvesZeroHPBossAsDefeated: a killing blow that committed
|
||||
// the pool to 0 but (crash/redeploy) never resolved the status must be resolved
|
||||
// as DEFEATED by the ticker net — never fall through to the survive/pot-debit
|
||||
// path. No contributors here, so resolution never touches euro.
|
||||
func TestWorldBossTick_ResolvesZeroHPBossAsDefeated(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
communityPotAdd(1000)
|
||||
bossID, err := insertWorldBoss("Ghost", 3, 100, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Get().Exec(`UPDATE world_boss SET hp_current = 0 WHERE id = ?`, bossID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.worldBossTick()
|
||||
after, _ := loadWorldBoss(bossID)
|
||||
if after.Status != "defeated" {
|
||||
t.Errorf("status = %q, want defeated (ticker safety net)", after.Status)
|
||||
}
|
||||
if bal := communityPotBalance(); bal != 1000 {
|
||||
t.Errorf("pot = %d, want 1000 — a defeat mints, it must not debit the pot", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldBossTick_ResolvesLapsedBossAsSurvived: a boss whose window lapsed with
|
||||
// the pool still up survives and loots the pot.
|
||||
func TestWorldBossTick_ResolvesLapsedBossAsSurvived(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
communityPotAdd(1000)
|
||||
bossID, err := insertWorldBoss("Titan", 4, 500, now.Add(-worldBossWindow), now.Add(-time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.worldBossTick()
|
||||
after, _ := loadWorldBoss(bossID)
|
||||
if after.Status != "survived" {
|
||||
t.Errorf("status = %q, want survived", after.Status)
|
||||
}
|
||||
if bal := communityPotBalance(); bal != 800 {
|
||||
t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
227
internal/plugin/bootstrap_pete_news.go
Normal file
227
internal/plugin/bootstrap_pete_news.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// bootstrapPeteNewsBackfill replays the back-catalogue through the news emit
|
||||
// path the first time the seam comes up enabled, so a launch doesn't open onto
|
||||
// an empty section. It covers the three tractable, high-signal sources:
|
||||
//
|
||||
// 1. Realm-first zone clears — the earliest boss-defeated run of each zone. This
|
||||
// also SEEDS news_realm_firsts, so a later live clear of an already-cleared
|
||||
// zone tiers as a BULLETIN repeat, not a mis-announced "first ever".
|
||||
// 2. Deaths — every player_meta row that carries a death (graveyard content).
|
||||
// 3. Single-holder achievements — rarity-gated to one holder, matching the live
|
||||
// milestone rule so routine unlocks don't flood the backlog.
|
||||
//
|
||||
// Backfilled facts are backdated to when they happened and marked NoPush (no
|
||||
// web-push spam on launch). GUIDs are deterministic (keyed on the historical
|
||||
// timestamp), so a re-run — or a later live emit of the same event — dedups on
|
||||
// Pete's side. Guarded one-shot; kept (never deleted at close-out) so a fresh
|
||||
// deploy doesn't ghost-town again. See pete_adventure_news_plan.md gap #7.
|
||||
func (p *AdventurePlugin) bootstrapPeteNewsBackfill() {
|
||||
const jobName = "pete_news_backfill_v1"
|
||||
if db.JobCompleted(jobName, "once") {
|
||||
return
|
||||
}
|
||||
// The backfill IS the launch content, so it should fire the first boot the
|
||||
// seam is actually live — not get silently consumed on a boot where emission
|
||||
// is off. Leave the job unmarked until both switches are on.
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
|
||||
firsts := p.backfillZoneFirsts()
|
||||
deaths := backfillDeaths()
|
||||
achv := p.backfillSoloAchievements()
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
slog.Warn("bootstrap: pete news backfill complete",
|
||||
"zone_firsts", firsts, "deaths", deaths, "achievements", achv)
|
||||
}
|
||||
|
||||
// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY
|
||||
// realm-first dispatch per zone, attributed to its earliest boss-defeating
|
||||
// clearer. SQLite returns the user_id from the same row as MIN(completed_at)
|
||||
// (bare-column min/max rule), so the (zone, first clearer, time) triple is
|
||||
// consistent. Returns the count emitted.
|
||||
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT zone_id, user_id, MIN(completed_at)
|
||||
FROM dnd_zone_run
|
||||
WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0
|
||||
GROUP BY zone_id`)
|
||||
if err != nil {
|
||||
slog.Error("backfill: zone-firsts query", "err", err)
|
||||
return 0
|
||||
}
|
||||
type first struct {
|
||||
zoneID, userID, completedAt string
|
||||
}
|
||||
var firsts []first
|
||||
for rows.Next() {
|
||||
var f first
|
||||
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
|
||||
slog.Error("backfill: zone-firsts scan", "err", err)
|
||||
continue
|
||||
}
|
||||
firsts = append(firsts, f)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
n := 0
|
||||
for _, f := range firsts {
|
||||
uid := id.UserID(f.userID)
|
||||
// Seed the ledger regardless of whether we can render a dispatch, so the
|
||||
// realm-first tiering is correct even for an unnamed straggler.
|
||||
claimRealmFirst("zone", f.zoneID)
|
||||
|
||||
name := charName(uid)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
ts, ok := parseSQLiteTime(f.completedAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
zone := zoneOrFallback(ZoneID(f.zoneID))
|
||||
lvl := charLevel(uid)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), f.zoneID, ts.Unix()),
|
||||
EventType: "zone_first",
|
||||
Tier: "priority",
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Boss: zone.Boss.Name,
|
||||
Level: lvl,
|
||||
Outcome: "cleared",
|
||||
OccurredAt: ts.Unix(),
|
||||
NoPush: true,
|
||||
}, uid, "")
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// backfillDeaths emits one death dispatch per player_meta death record. Deaths
|
||||
// are day-granular (last_death_date), so the dispatch is backdated to that day.
|
||||
func backfillDeaths() int {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT user_id, last_death_date, death_location
|
||||
FROM player_meta
|
||||
WHERE last_death_date != ''`)
|
||||
if err != nil {
|
||||
slog.Error("backfill: deaths query", "err", err)
|
||||
return 0
|
||||
}
|
||||
type death struct {
|
||||
userID, date, location string
|
||||
}
|
||||
var deaths []death
|
||||
for rows.Next() {
|
||||
var d death
|
||||
if err := rows.Scan(&d.userID, &d.date, &d.location); err != nil {
|
||||
slog.Error("backfill: deaths scan", "err", err)
|
||||
continue
|
||||
}
|
||||
deaths = append(deaths, d)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
n := 0
|
||||
for _, d := range deaths {
|
||||
uid := id.UserID(d.userID)
|
||||
name := charName(uid)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
day, err := time.Parse("2006-01-02", d.date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lvl := charLevel(uid)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date),
|
||||
EventType: "death",
|
||||
Tier: "priority",
|
||||
Subject: name,
|
||||
Zone: d.location,
|
||||
Level: lvl,
|
||||
Outcome: "lost",
|
||||
OccurredAt: day.UTC().Unix(),
|
||||
NoPush: true,
|
||||
}, uid, "")
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// backfillSoloAchievements emits a milestone dispatch for every achievement held
|
||||
// by exactly one player — the same rarity gate the live path uses — so the
|
||||
// backlog carries the genuinely distinctive unlocks, not routine ones.
|
||||
func (p *AdventurePlugin) backfillSoloAchievements() int {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT achievement_id, user_id, unlocked_at
|
||||
FROM achievements
|
||||
GROUP BY achievement_id
|
||||
HAVING COUNT(*) = 1`)
|
||||
if err != nil {
|
||||
slog.Error("backfill: achievements query", "err", err)
|
||||
return 0
|
||||
}
|
||||
type solo struct {
|
||||
achID, userID string
|
||||
unlockedAt int64
|
||||
}
|
||||
var solos []solo
|
||||
for rows.Next() {
|
||||
var s solo
|
||||
if err := rows.Scan(&s.achID, &s.userID, &s.unlockedAt); err != nil {
|
||||
slog.Error("backfill: achievements scan", "err", err)
|
||||
continue
|
||||
}
|
||||
solos = append(solos, s)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
n := 0
|
||||
for _, s := range solos {
|
||||
uid := id.UserID(s.userID)
|
||||
name := charName(uid)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
label := s.achID
|
||||
if p.achievements != nil {
|
||||
for _, a := range p.achievements.achievements {
|
||||
if a.ID == s.achID {
|
||||
label = a.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
ts := s.unlockedAt
|
||||
if ts == 0 {
|
||||
ts = nowUnix()
|
||||
}
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(uid, s.achID), s.achID),
|
||||
EventType: "milestone",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Milestone: label,
|
||||
OccurredAt: ts,
|
||||
NoPush: true,
|
||||
}, uid, "")
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
174
internal/plugin/combat_ally_heal_test.go
Normal file
174
internal/plugin/combat_ally_heal_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// §1 — the cleric fix.
|
||||
//
|
||||
// Until this landed, EVERY heal in the combat engine was self-scoped:
|
||||
// MistyHealProc healed the actor, HealItem fired the actor's own trigger, and
|
||||
// turnActionEffect.PlayerHeal wrote to the acting seat. There was no action of any
|
||||
// kind that could touch another seat's HP. A party cleric — the class whose entire
|
||||
// identity is keeping other people upright — could not put one hit point on a
|
||||
// friend, and N3 shipped that way without a single test going red, because party
|
||||
// combat had no golden.
|
||||
//
|
||||
// These tests exist so that can never be true again.
|
||||
|
||||
// startAllyHealFight seats a healer at 0 and a hurt friend at 1.
|
||||
func startAllyHealFight(t *testing.T, p *AdventurePlugin, hurtHP int) *CombatSession {
|
||||
t.Helper()
|
||||
healer := basePlayer()
|
||||
friend := basePlayer()
|
||||
enemy := Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}
|
||||
|
||||
// Distinct ids per fight: one active session per player is enforced, so two
|
||||
// fights sharing a healer would collide.
|
||||
tag := strings.ReplaceAll(t.Name(), "/", "_")
|
||||
sess, err := p.startPartyCombatSession("run-"+tag, "enc", "goblin", &enemy, []CombatSeatSetup{
|
||||
{UserID: healerID(tag), HP: 100, HPMax: 100, Mods: healer.Mods, C: &healer},
|
||||
{UserID: friendID(tag), HP: hurtHP, HPMax: 100, Mods: friend.Mods, C: &friend},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func healerID(tag string) id.UserID { return id.UserID("@healer-" + tag + ":example.org") }
|
||||
func friendID(tag string) id.UserID { return id.UserID("@friend-" + tag + ":example.org") }
|
||||
|
||||
func TestAllyHeal_LandsOnTheFriendNotTheCaster(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
sess := startAllyHealFight(t, p, 20)
|
||||
|
||||
healer, friend := basePlayer(), basePlayer()
|
||||
players := []*Combatant{&healer, &friend}
|
||||
enemy := Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}
|
||||
ct := &combatTurn{sess: sess, players: players, enemy: &enemy, seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||
|
||||
casterBefore, friendBefore := sess.seatHP(0), sess.seatHP(1)
|
||||
|
||||
_, err := p.driveCombatRound(ct, PlayerAction{
|
||||
Kind: ActionCast,
|
||||
Effect: &turnActionEffect{
|
||||
Label: "Cure Wounds", Action: "spell_cast",
|
||||
AllyHeal: 30, AllySeat: 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := sess.seatHP(1); got != friendBefore+30 {
|
||||
t.Errorf("friend HP = %d, want %d — the heal did not reach them. "+
|
||||
"This is the bug: a cleric who cannot heal anyone but themselves.",
|
||||
got, friendBefore+30)
|
||||
}
|
||||
if got := sess.seatHP(0); got > casterBefore {
|
||||
t.Errorf("caster HP = %d (was %d) — the heal landed on the caster instead of the target",
|
||||
got, casterBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// A heal cannot exceed the target's ceiling, and cannot raise the dead. Death is
|
||||
// terminal for the fight — the close-out marks them, the hospital takes them — and
|
||||
// a heal that resurrected a corpse would quietly rewrite the loss rules every
|
||||
// close-out path depends on.
|
||||
func TestAllyHeal_CapsAtMaxAndWillNotRaiseTheDead(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
t.Run("caps at max", func(t *testing.T) {
|
||||
sess := startAllyHealFight(t, p, 90)
|
||||
healer, friend := basePlayer(), basePlayer()
|
||||
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||
enemy: &Combatant{Name: "d", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
|
||||
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||
|
||||
if _, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
|
||||
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 500, AllySeat: 1},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := sess.seatHP(1), sess.seatHPMax(1); got > want {
|
||||
t.Errorf("friend healed to %d over a max of %d", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("will not raise the dead", func(t *testing.T) {
|
||||
sess := startAllyHealFight(t, p, 0) // seat 1 is already down
|
||||
healer, friend := basePlayer(), basePlayer()
|
||||
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||
enemy: &Combatant{Name: "d", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
|
||||
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||
|
||||
if _, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
|
||||
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 50, AllySeat: 1},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sess.seatHP(1); got > 0 {
|
||||
t.Errorf("a downed seat was healed to %d — healing keeps people up, it does not bring them back", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The target parser resolves against the people in the fight, and leaves anything
|
||||
// it does not recognise on the string for the spell parser — so every existing
|
||||
// `!cast` form still means what it always meant.
|
||||
func TestSplitCastTarget(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
sess := startAllyHealFight(t, p, 50)
|
||||
healer, friend := basePlayer(), basePlayer()
|
||||
healer.Name, friend.Name = "Ayla", "Bram"
|
||||
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||
enemy: &Combatant{Name: "d"}, seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||
|
||||
tName := t.Name()
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
wantArgs string
|
||||
wantSeat int
|
||||
wantError bool
|
||||
}{
|
||||
{"no target", "cure wounds", "cure wounds", -1, false},
|
||||
{"slot level is not a target", "fireball 3", "fireball 3", -1, false},
|
||||
{"@mention by display name", "cure wounds @Bram", "cure wounds", 1, false},
|
||||
// The flag !help has advertised all along, and that parseCombatCast used to
|
||||
// accept and silently throw away ("reserved for SP3").
|
||||
{"--target flag", "cure wounds --target @Bram", "cure wounds", 1, false},
|
||||
{"--target mid-string", "cure wounds --target Bram", "cure wounds", 1, false},
|
||||
{"--target with no name", "cure wounds --target", "", -1, true},
|
||||
{"bare display name", "cure wounds Bram", "cure wounds", 1, false},
|
||||
{"by localpart", "cure wounds @" + friendID(strings.ReplaceAll(tName, "/", "_")).Localpart(), "cure wounds", 1, false},
|
||||
{"targeting yourself is just casting it", "cure wounds @Ayla", "cure wounds", -1, false},
|
||||
{"@mention of a stranger is an error", "cure wounds @nobody", "", -1, true},
|
||||
{"an unknown bare word is left for the spell parser", "mass cure wounds", "mass cure wounds", -1, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args, seat, errMsg := splitCastTarget(ct, 0, tc.in)
|
||||
if tc.wantError {
|
||||
if errMsg == "" {
|
||||
t.Fatalf("splitCastTarget(%q) gave no error; a mistyped @mention would silently waste the slot", tc.in)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errMsg != "" {
|
||||
t.Fatalf("splitCastTarget(%q) errored: %s", tc.in, errMsg)
|
||||
}
|
||||
if args != tc.wantArgs || seat != tc.wantSeat {
|
||||
t.Errorf("splitCastTarget(%q) = (%q, %d), want (%q, %d)",
|
||||
tc.in, args, seat, tc.wantArgs, tc.wantSeat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,12 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
|
||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
|
||||
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
|
||||
// which combat stat derivation never reads (see adventure_renown.go).
|
||||
applyRenownBonuses(b, char.RenownLevel())
|
||||
return b
|
||||
}
|
||||
|
||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||
|
||||
@@ -105,7 +105,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
// this is a no-op there); mirror it here so the entry banner and the opening
|
||||
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||||
// the rebuilt rounds use.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
|
||||
|
||||
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
||||
// per seat onto the session and its participant rows, so they survive the
|
||||
@@ -381,6 +381,9 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite, elite); drop != "" {
|
||||
b.WriteString(drop + "\n")
|
||||
}
|
||||
if !elite {
|
||||
writeBossEpilogue(&b, zone.ID)
|
||||
}
|
||||
if bossOnExpedition {
|
||||
// The boss is the expedition's climax. Frame the close-out as
|
||||
// the win rather than a "keep walking" nudge. One more
|
||||
@@ -422,7 +425,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
// returns the spell plus the resolved slot level. errMsg is non-empty and
|
||||
// player-facing on any validation failure. It performs NO resource spend —
|
||||
// the caller debits the slot only once the round is about to resolve.
|
||||
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
||||
//
|
||||
// It takes the seat, not just the user, because "do you know this spell" is a
|
||||
// question about a combatant and only *usually* a question about a database row:
|
||||
// the hired companion has no rows and answers it from his synthetic sheet.
|
||||
func parseCombatCast(sess *CombatSession, seat int, userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
||||
tokens := strings.Fields(args)
|
||||
upcast := 0
|
||||
var spellTokens []string
|
||||
@@ -470,7 +477,7 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
|
||||
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
|
||||
}
|
||||
}
|
||||
known, prepared, err := playerKnowsSpell(userID, spell.ID)
|
||||
known, prepared, err := seatKnowsSpell(sess, seat, userID, spell.ID)
|
||||
if err != nil {
|
||||
return SpellDefinition{}, 0, "Couldn't check your spell list."
|
||||
}
|
||||
@@ -523,12 +530,92 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
||||
// the roster so the buff is live for the round's enemy turn. Before P5 that
|
||||
// delta went to the session's embedded copy — seat 0 — so a party member
|
||||
// buffing themselves would have buffed the leader.
|
||||
// splitCastTarget peels an optional ally target off the end of a `!cast` argument
|
||||
// — `cure wounds @alex`, or just `cure wounds alex` — and resolves it to a seat.
|
||||
//
|
||||
// It resolves against the people *in this fight* rather than the room, which is
|
||||
// both cheaper (no ResolveUser round-trip, no RoomID to thread down here) and
|
||||
// more correct: the only legal target of a combat heal is somebody sitting in the
|
||||
// combat. Anything it does not recognise is left on the string for the spell
|
||||
// parser, so `!cast cure wounds` and `!cast fireball 3` behave exactly as before.
|
||||
//
|
||||
// Both spellings work: the `--target @alex` flag that `!help` has advertised all
|
||||
// along (and that parseCombatCast has been silently swallowing since SP2 —
|
||||
// "reserved for SP3, accept and ignore"), and a plain trailing `@alex`.
|
||||
//
|
||||
// Returns (remainingArgs, seat, errMsg). seat is -1 when no target was named.
|
||||
func splitCastTarget(ct *combatTurn, caster int, args string) (string, int, string) {
|
||||
args = strings.TrimSpace(args)
|
||||
if args == "" || !ct.isParty() {
|
||||
return args, -1, ""
|
||||
}
|
||||
fields := strings.Fields(args)
|
||||
|
||||
// `--target <who>` anywhere in the string.
|
||||
explicit, name := false, ""
|
||||
for i := 0; i < len(fields); i++ {
|
||||
if !strings.EqualFold(fields[i], "--target") {
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(fields) {
|
||||
return args, -1, "`--target` needs a name: `!cast cure wounds --target @alex`."
|
||||
}
|
||||
explicit, name = true, strings.TrimPrefix(fields[i+1], "@")
|
||||
fields = append(fields[:i], fields[i+2:]...)
|
||||
break
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
last := fields[len(fields)-1]
|
||||
// A bare number is a slot level (`!cast fireball 3`), never a target.
|
||||
if _, err := strconv.Atoi(last); err == nil {
|
||||
return args, -1, ""
|
||||
}
|
||||
explicit = strings.HasPrefix(last, "@")
|
||||
name = strings.TrimPrefix(last, "@")
|
||||
if name == "" {
|
||||
return args, -1, ""
|
||||
}
|
||||
fields = fields[:len(fields)-1]
|
||||
}
|
||||
// From here `fields` is the spell tokens with the target removed.
|
||||
rest := strings.Join(fields, " ")
|
||||
|
||||
for i, c := range ct.players {
|
||||
uid := ct.sess.seatUserID(i)
|
||||
if !strings.EqualFold(c.Name, name) &&
|
||||
!strings.EqualFold(uid, name) &&
|
||||
!strings.EqualFold(id.UserID(uid).Localpart(), name) {
|
||||
continue
|
||||
}
|
||||
if i == caster {
|
||||
// Targeting yourself is just casting it on yourself, which is what the
|
||||
// engine does by default. Drop the target and carry on.
|
||||
return rest, -1, ""
|
||||
}
|
||||
return rest, i, ""
|
||||
}
|
||||
// An explicit @mention that matches nobody in the fight is a mistake worth
|
||||
// naming — silently casting it on yourself would waste the slot.
|
||||
if explicit {
|
||||
return args, -1, fmt.Sprintf("**%s** isn't in this fight. Cast it on someone who is.", name)
|
||||
}
|
||||
return args, -1, ""
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||||
noop := func(bool) {}
|
||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||
|
||||
advChar, _ := loadAdvCharacter(uid)
|
||||
c, err := p.ensureCharForDnDCmd(uid, advChar)
|
||||
// §1 — a heal may name somebody else in the fight: `!cast cure wounds @alex`.
|
||||
// Split the target off before the spell is parsed, so the spell parser sees
|
||||
// the same string it always has.
|
||||
args, targetSeat, targetErr := splitCastTarget(ct, seat, args)
|
||||
if targetErr != "" {
|
||||
return PlayerAction{}, noop, targetErr
|
||||
}
|
||||
|
||||
c, err := p.seatCastSheet(ct.sess, uid)
|
||||
if err != nil || c == nil {
|
||||
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
|
||||
}
|
||||
@@ -537,7 +624,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
|
||||
}
|
||||
|
||||
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
|
||||
spell, slotLevel, errMsg := parseCombatCast(ct.sess, seat, uid, c, strings.TrimSpace(args))
|
||||
if errMsg != "" {
|
||||
return PlayerAction{}, noop, errMsg
|
||||
}
|
||||
@@ -548,7 +635,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
|
||||
refund := func(ok bool) {
|
||||
if !ok && spell.Level > 0 {
|
||||
_ = refundSpellSlot(uid, slotLevel)
|
||||
_ = refundSeatSlot(ct.sess, seat, uid, slotLevel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +652,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"%s has no effect the turn-based engine can apply yet.", spell.Name)
|
||||
}
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||||
@@ -587,7 +674,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
|
||||
}
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
// Park the Necromancy kill-heal stash on the casting seat. The
|
||||
@@ -609,6 +696,20 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
PlayerHeal: out.PlayerHeal,
|
||||
EnemySkip: out.EnemySkip,
|
||||
}
|
||||
// §1 — redirect the heal onto the named ally. The roll is the same; only
|
||||
// the body it lands on changes. This is the line that makes a cleric a
|
||||
// cleric: until it existed, every heal in the engine was a self-heal, and
|
||||
// the class whose whole job is keeping other people upright could not put
|
||||
// a single hit point on a friend.
|
||||
if targetSeat >= 0 && targetSeat != seat {
|
||||
if out.PlayerHeal <= 0 {
|
||||
return PlayerAction{}, refund, fmt.Sprintf(
|
||||
"%s isn't something you can cast on someone else. Drop the target to cast it yourself.", spell.Name)
|
||||
}
|
||||
eff.AllyHeal, eff.AllySeat = out.PlayerHeal, targetSeat
|
||||
eff.PlayerHeal = 0
|
||||
eff.Label = fmt.Sprintf("%s → %s (+%d HP)", spell.Name, ct.players[targetSeat].Name, out.PlayerHeal)
|
||||
}
|
||||
// Concentration AOE damage spells linger: the burst lands this round
|
||||
// (EnemyDamage) and the same value re-ticks every round_end after, via
|
||||
// the engine's concentration aura. spiritual_weapon already covers the
|
||||
@@ -637,18 +738,30 @@ func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
|
||||
// success the caller owns the slot and must refundSpellSlot if the round itself
|
||||
// errors. Material components (rare in a fight) are not refunded if the slot
|
||||
// debit then fails — matching the auto-resolve cast path.
|
||||
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||||
func (p *AdventurePlugin) chargeSpellCost(sess *CombatSession, seat int, userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||||
_, _, companion := seatCompanionLoadout(sess, userID)
|
||||
|
||||
// The companion carries no purse — he has no wallet to debit and no inventory
|
||||
// to stock one from, so a component cost is not a price he can pay but a spell
|
||||
// he cannot cast. Refusing here (rather than letting the debit fail on an empty
|
||||
// account) keeps that an explicit rule instead of an accident of his balance.
|
||||
if spell.MaterialCost > 0 {
|
||||
if companion {
|
||||
return fmt.Sprintf("%s needs a component %s doesn't carry.", spell.Name, companionDisplayName)
|
||||
}
|
||||
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
||||
}
|
||||
}
|
||||
if spell.Level > 0 {
|
||||
ok, serr := consumeSpellSlot(userID, slotLevel)
|
||||
ok, serr := consumeSeatSlot(sess, seat, userID, slotLevel)
|
||||
if serr != nil {
|
||||
return "Couldn't consume slot: " + serr.Error()
|
||||
}
|
||||
if !ok {
|
||||
if companion {
|
||||
return fmt.Sprintf("%s is out of level-%d energy.", companionDisplayName, slotLevel)
|
||||
}
|
||||
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,22 @@ type Combatant struct {
|
||||
Mods CombatModifiers
|
||||
IsPlayer bool
|
||||
Ability *MonsterAbility // non-nil for monsters with a special ability
|
||||
|
||||
// SeatWeight is what this seat costs the enemy: 1.0 is a full peer of the
|
||||
// leader, and less than that is a seat that brings less to the fight. Zero
|
||||
// means "unset" and reads as 1.0, so every existing call site — and every solo
|
||||
// fight — is unchanged.
|
||||
//
|
||||
// The enemy's HP bump and its action economy scale on the SUM of these rather
|
||||
// than on a seat count. A seat count charges the boss the same for an
|
||||
// under-levelled friend, a hired NPC, and a peer, which is why hiring a
|
||||
// below-median body was measurably worse than going alone: he cost a full
|
||||
// seat's worth of boss and did not give a full seat's worth back.
|
||||
//
|
||||
// It is derived from the seat's identity (level, and whether it is a hireling),
|
||||
// NOT from fight state — so every per-round rebuild recomputes the same number
|
||||
// and there is nothing to persist. See seatWeight.
|
||||
SeatWeight float64
|
||||
}
|
||||
|
||||
type CombatPhase struct {
|
||||
@@ -220,7 +236,14 @@ type CombatEvent struct {
|
||||
//
|
||||
// It exists so a party's play-by-play can name the right person. Solo events
|
||||
// are all seat 0, and the omitempty tag keeps the field out of every solo
|
||||
// turn_log_json — rows written before N3/P5 decode unchanged.
|
||||
// turn_log_json — rows written before N3/P5 decode unchanged, and a fight in
|
||||
// flight across a deploy resumes byte-identically (TestP5Fields_StayOffSoloRows).
|
||||
//
|
||||
// The omitempty makes seat 0 and "no seat" identical on the wire, which is
|
||||
// fine for persistence and actively misleading in a diagnostic trace — it hid
|
||||
// a companion who never swung, making the fight look like it had one seat.
|
||||
// Do NOT fix that here; the wire format is load-bearing. The sim's trace
|
||||
// serializes through simTraceEvent, which always emits the seat.
|
||||
Seat int `json:"Seat,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatP
|
||||
// scaled action economy to actually threaten each member. Solo is unchanged
|
||||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||||
if len(players) > 1 {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players))
|
||||
}
|
||||
enemyStart := enemy.Stats.MaxHP
|
||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||
@@ -307,11 +307,26 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
|
||||
// between — a per-round coin-flip for the extra action is the only way to land
|
||||
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
||||
// stream is untouched.
|
||||
// §2(b): the budget counts the seats still STANDING, re-derived every round —
|
||||
// not the seats that walked in.
|
||||
//
|
||||
// It used to read len(st.actors), which includes the dead. So a party that lost a
|
||||
// member kept paying for them: the survivor faced a boss still swinging at
|
||||
// two-player cadence, alone. That is a death spiral with the arrow pointing the
|
||||
// wrong way — the moment a party is losing, the engine hits it harder — and it is
|
||||
// the single nastiest thing the companion sweep turned up, because it is not a
|
||||
// companion bug at all. It has been live for every human party since N3.
|
||||
//
|
||||
// A corpse does not threaten anybody, and the enemy has no reason to keep spending
|
||||
// actions on one.
|
||||
func enemyActionsThisRound(st *combatState) int {
|
||||
n := len(st.actors)
|
||||
if n < 2 {
|
||||
if livingActors(st) < 2 {
|
||||
return 1
|
||||
}
|
||||
// The summed weight of the seats still standing — not a head count of them.
|
||||
// A seat that brings half a peer buys the boss half a peer's worth of extra
|
||||
// swings, and a seat that is down buys none at all.
|
||||
n := livingWeight(st)
|
||||
exp := partyActionExpectation(n)
|
||||
base := int(exp)
|
||||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||||
@@ -320,6 +335,29 @@ func enemyActionsThisRound(st *combatState) int {
|
||||
return base
|
||||
}
|
||||
|
||||
// livingActors counts the seats still standing.
|
||||
func livingActors(st *combatState) int {
|
||||
n := 0
|
||||
for _, a := range st.actors {
|
||||
if a.playerHP > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// livingWeight is livingActors in the currency the enemy actually charges in: the
|
||||
// summed SeatWeight of the seats still standing.
|
||||
func livingWeight(st *combatState) float64 {
|
||||
total := 0.0
|
||||
for _, a := range st.actors {
|
||||
if a.playerHP > 0 {
|
||||
total += combatantWeight(a.c)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// partyActionExpectation is the expected number of enemy attack-actions per round
|
||||
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
||||
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
||||
@@ -333,17 +371,108 @@ func enemyActionsThisRound(st *combatState) int {
|
||||
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
||||
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
||||
// composition below its solo clear rate — bringing a friend is never a penalty.
|
||||
func partyActionExpectation(n int) float64 {
|
||||
// It takes a fractional party size — the summed SeatWeight of the living seats,
|
||||
// not a head count — and interpolates linearly between the integer knots the P8
|
||||
// sweep actually tuned: (1, 1.0), (2, 2.4), and 2n−1 from 3 up. Every integer
|
||||
// input therefore returns exactly what it always returned, so a solo fight and a
|
||||
// party of peers are byte-identical and the balance corpus is untouched. Only a
|
||||
// roster of *unequal* seats lands between the knots, which is the entire point:
|
||||
// a half-strength body should buy the boss half a body's worth of extra swings.
|
||||
func partyActionExpectation(n float64) float64 {
|
||||
switch {
|
||||
case n < 2:
|
||||
case n <= 1:
|
||||
return 1
|
||||
case n == 2:
|
||||
return 2.4
|
||||
case n <= 2:
|
||||
// (1, 1.0) → (2, 2.4)
|
||||
return 1 + 1.4*(n-1)
|
||||
case n <= 3:
|
||||
// (2, 2.4) → (3, 5.0); the original curve stepped here, so the segment is
|
||||
// steeper than the 2n−1 line it joins.
|
||||
return 2.4 + 2.6*(n-2)
|
||||
default:
|
||||
return float64(2*n - 1)
|
||||
return 2*n - 1
|
||||
}
|
||||
}
|
||||
|
||||
// seatWeight is what one seat costs the enemy, relative to the leader.
|
||||
//
|
||||
// **Level, not stats.** A power score built from HP × damage would rank a cleric
|
||||
// below a fighter and quietly make every mixed *human* party easier — the support
|
||||
// classes would stop paying their way. Level is the class-neutral measure of what
|
||||
// a body is worth, and it is exactly the axis on which the two seats that violated
|
||||
// the peer assumption differ: an under-levelled friend, and a hireling who arrives
|
||||
// a level down by design.
|
||||
//
|
||||
// A peer of the leader weighs 1.0, so a party of equals is unchanged. Nobody weighs
|
||||
// more than a peer: out-levelling the leader does not make the boss harder for
|
||||
// everyone else.
|
||||
func seatWeight(seatLevel, leaderLevel int, companion bool) float64 {
|
||||
if leaderLevel <= 0 || seatLevel <= 0 {
|
||||
return 1
|
||||
}
|
||||
w := float64(seatLevel) / float64(leaderLevel)
|
||||
if w > 1 {
|
||||
w = 1
|
||||
}
|
||||
if companion {
|
||||
// The layers a player accumulates and a hireling never will: no subclass, no
|
||||
// magic items, no armed ability, and gear that is never Masterwork. Levels
|
||||
// cannot see any of that, so it is priced here.
|
||||
w *= companionSeatWeight
|
||||
}
|
||||
if w < seatWeightFloor {
|
||||
w = seatWeightFloor
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
const (
|
||||
// companionSeatWeight is the hireling discount — the one tunable in this model.
|
||||
companionSeatWeight = 0.65
|
||||
// seatWeightFloor stops a badly under-levelled seat from becoming free. A body
|
||||
// in the fight is always worth something to the enemy: it is one more thing that
|
||||
// has to be killed.
|
||||
seatWeightFloor = 0.35
|
||||
)
|
||||
|
||||
// partyWeight sums the seats' weights. An unset weight (0) reads as a full peer,
|
||||
// which is what every combatant built before this existed is.
|
||||
func partyWeight(players []*Combatant) float64 {
|
||||
total := 0.0
|
||||
for _, c := range players {
|
||||
total += combatantWeight(c)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func combatantWeight(c *Combatant) float64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
if c.SeatWeight <= 0 {
|
||||
return 1
|
||||
}
|
||||
return c.SeatWeight
|
||||
}
|
||||
|
||||
// partyWeightOf is partyWeight for a value slice.
|
||||
func partyWeightOf(players []Combatant) float64 {
|
||||
total := 0.0
|
||||
for i := range players {
|
||||
total += combatantWeight(&players[i])
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// seatSetupWeight is partyWeight for a roster that is still being seated.
|
||||
func seatSetupWeight(seats []CombatSeatSetup) float64 {
|
||||
total := 0.0
|
||||
for _, s := range seats {
|
||||
total += combatantWeight(s.C)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
||||
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
||||
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
||||
@@ -355,18 +484,27 @@ func partyActionExpectation(n int) float64 {
|
||||
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
||||
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||||
// not the 100% faceroll a single enemy swing produced.
|
||||
func partyEnemyHPScale(rosterSize int) float64 {
|
||||
if rosterSize < 2 {
|
||||
// Like partyActionExpectation it takes the summed SeatWeight rather than a head
|
||||
// count, and ramps between the same knots: weight 1 (solo) pays nothing, weight 2
|
||||
// (a peer at the leader's side) pays the full 1.15 the P8 sweep tuned. Integer
|
||||
// inputs are byte-exact, so solo and a party of peers are unchanged; a roster
|
||||
// carrying a below-median body pays proportionally less, because it brought
|
||||
// proportionally less.
|
||||
func partyEnemyHPScale(weight float64) float64 {
|
||||
if weight <= 1 {
|
||||
return 1.0
|
||||
}
|
||||
return 1.15
|
||||
if weight >= 2 {
|
||||
return 1.15
|
||||
}
|
||||
return 1.0 + 0.15*(weight-1)
|
||||
}
|
||||
|
||||
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
||||
// rule, so every call site (the auto-resolve engine, the party session's initial
|
||||
// persist, and the per-turn rebuild) agrees on the same number.
|
||||
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
|
||||
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
|
||||
func scaledEnemyMaxHP(baseMaxHP int, weight float64) int {
|
||||
return int(float64(baseMaxHP) * partyEnemyHPScale(weight))
|
||||
}
|
||||
|
||||
// enemyActionPlan is the shared action budget both combat engines resolve an
|
||||
|
||||
@@ -142,11 +142,22 @@ func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
|
||||
if got := partyActionExpectation(2); got != 2.4 {
|
||||
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
||||
}
|
||||
// The curve now takes a fractional weight rather than a head count, so that a
|
||||
// below-median seat costs the enemy less than a peer does. Every INTEGER input
|
||||
// must still return exactly what it always returned — that is what keeps solo
|
||||
// and a party of peers byte-identical, and the balance corpus with them.
|
||||
for n := 3; n <= 5; n++ {
|
||||
if got, want := partyActionExpectation(n), float64(2*n-1); got != want {
|
||||
if got, want := partyActionExpectation(float64(n)), float64(2*n-1); got != want {
|
||||
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
|
||||
}
|
||||
}
|
||||
// A duo carrying a half-strength body sits between soloing and a true duo.
|
||||
if got := partyActionExpectation(1.5); got <= 1 || got >= 2.4 {
|
||||
t.Fatalf("weight 1.5 action expectation = %v, want strictly between 1 and 2.4", got)
|
||||
}
|
||||
if got := partyEnemyHPScale(1.5); got <= 1.0 || got >= 1.15 {
|
||||
t.Fatalf("weight 1.5 HP scale = %v, want strictly between 1.0 and 1.15", got)
|
||||
}
|
||||
if got := partyEnemyHPScale(3); got != 1.15 {
|
||||
t.Fatalf("party HP scale = %v, want 1.15", got)
|
||||
}
|
||||
|
||||
159
internal/plugin/combat_party_characterization_test.go
Normal file
159
internal/plugin/combat_party_characterization_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Party characterization — the net that did not exist.
|
||||
//
|
||||
// TestCombatCharacterization pins SOLO combat exhaustively, and it has been the
|
||||
// tripwire for every balance change since. Party combat had nothing. The whole
|
||||
// N-body layer — initiative order, per-seat resolution, enemy targeting, the P8
|
||||
// action-economy and HP scaling, downed-seat handling — shipped unpinned.
|
||||
//
|
||||
// What that cost: N3 shipped a Cleric class that cannot heal a single ally (no
|
||||
// action in the engine can target another seat) and not one test went red. The
|
||||
// hired companion then stood in fights doing nothing, and the unit tests stayed
|
||||
// green through that too. It took a 1500-run expedition sweep to see either.
|
||||
//
|
||||
// So: pin it. Any change to the party engine now has to state, in the diff, what
|
||||
// it moved. Regenerate ONLY on purpose:
|
||||
//
|
||||
// go test ./internal/plugin -run TestPartyCharacterization -update
|
||||
//
|
||||
// This golden covers simulateParty — the auto-resolve engine, which decides most
|
||||
// expedition rooms and which the balance harness is built on. The turn engine's
|
||||
// party half (manual play + boss fights) needs DB fixtures and is pinned by
|
||||
// combat_turn_party_test.go; widening THAT into a golden is the follow-up.
|
||||
|
||||
var updatePartyGolden = flag.Bool("update-party", false, "rewrite the party characterization golden")
|
||||
|
||||
// partyScenario is one pinned N-body fight.
|
||||
type partyScenario struct {
|
||||
name string
|
||||
seats []Combatant
|
||||
enemy Combatant
|
||||
phases []CombatPhase
|
||||
}
|
||||
|
||||
// seatOfClass shapes a seat that stands in for a class archetype. These are
|
||||
// deliberately engine-level (stat blocks, not sheets): the golden pins what the
|
||||
// ENGINE does with a roster, not what the character layer feeds it.
|
||||
func seatOfClass(name string, hp, atk, def, speed int) Combatant {
|
||||
c := basePlayer()
|
||||
c.Name = name
|
||||
c.Stats.MaxHP = hp
|
||||
c.Stats.Attack = atk
|
||||
c.Stats.Defense = def
|
||||
c.Stats.Speed = speed
|
||||
return c
|
||||
}
|
||||
|
||||
func partyCharacterizationScenarios() []partyScenario {
|
||||
tank := seatOfClass("Tank", 160, 14, 12, 8)
|
||||
striker := seatOfClass("Striker", 90, 22, 6, 14)
|
||||
support := seatOfClass("Support", 110, 11, 9, 10)
|
||||
// The seat this whole plan is about: a body that is real but below median.
|
||||
// If scaling ever stops overcharging for it, THIS line is what moves.
|
||||
weak := seatOfClass("Weak", 70, 8, 5, 9)
|
||||
// A seat that will fall early. Its corpse must not keep buffing the enemy —
|
||||
// when §2 lands, this scenario is the one that proves it.
|
||||
glass := seatOfClass("Glass", 12, 18, 0, 16)
|
||||
|
||||
return []partyScenario{
|
||||
{"duo/even", []Combatant{tank, striker}, tankyEnemy(), dungeonCombatPhases},
|
||||
{"duo/tank+support", []Combatant{tank, support}, tankyEnemy(), dungeonCombatPhases},
|
||||
{"duo/median+weak", []Combatant{tank, weak}, tankyEnemy(), dungeonCombatPhases},
|
||||
{"duo/glass falls early", []Combatant{tank, glass}, hardHitEnemy(), dungeonCombatPhases},
|
||||
{"trio/even", []Combatant{tank, striker, support}, tankyEnemy(), dungeonCombatPhases},
|
||||
{"trio/one weak seat", []Combatant{tank, striker, weak}, tankyEnemy(), dungeonCombatPhases},
|
||||
{"trio/two glass seats", []Combatant{tank, glass, glass}, hardHitEnemy(), dungeonCombatPhases},
|
||||
{"duo/vs ability enemy", []Combatant{tank, striker},
|
||||
abilityEnemy("Wither", "poison", "Duel"), dungeonCombatPhases},
|
||||
// The degenerate case. A one-seat roster MUST stay bit-identical to solo —
|
||||
// it is the invariant the entire balance corpus rests on, and the reason
|
||||
// the solo golden is allowed to stay untouched while this file grows.
|
||||
{"solo/one-seat roster", []Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases},
|
||||
}
|
||||
}
|
||||
|
||||
// formatPartyResult prints the seat on EVERY line. The solo formatter does not
|
||||
// (it cannot; there is only one seat), and CombatEvent.Seat is `omitempty`, so a
|
||||
// JSON trace renders seat 0 and "no seat" identically — which sent me chasing a
|
||||
// phantom "the companion never swings" bug for an hour. A party golden that
|
||||
// could not tell you WHO acted would be worth very little.
|
||||
func formatPartyResult(r PartyCombatResult) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "result: won=%v timedOut=%v rounds=%d survivors=%v\n",
|
||||
r.PlayerWon, r.TimedOut, r.TotalRounds, r.AnySurvivor())
|
||||
fmt.Fprintf(&b, " enemy: start=%d entry=%d end=%d\n", r.EnemyStartHP, r.EnemyEntryHP, r.EnemyEndHP)
|
||||
for i, s := range r.Seats {
|
||||
fmt.Fprintf(&b, " seat[%d]: start=%d entry=%d end=%d\n",
|
||||
i, s.PlayerStartHP, s.PlayerEntryHP, s.PlayerEndHP)
|
||||
}
|
||||
for i, e := range r.Events {
|
||||
fmt.Fprintf(&b, " [%02d] r%d seat=%d %-12s %-8s %-16s dmg=%-5d php=%-4d ehp=%-4d roll=%d/%d desc=%q\n",
|
||||
i, e.Round, e.Seat, e.Phase, e.Actor, e.Action, e.Damage, e.PlayerHP, e.EnemyHP,
|
||||
e.Roll, e.RollAgainst, e.Desc)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestPartyCharacterization(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for _, sc := range partyCharacterizationScenarios() {
|
||||
for _, seed := range charSeeds {
|
||||
rng := rand.New(rand.NewPCG(seed, 0xC0FFEE))
|
||||
res := simulatePartyWithRNG(sc.seats, sc.enemy, sc.phases, rng)
|
||||
fmt.Fprintf(&b, "=== %s seed=%d ===\n", sc.name, seed)
|
||||
b.WriteString(formatPartyResult(res))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
got := b.String()
|
||||
|
||||
path := filepath.Join("testdata", "party_characterization.golden")
|
||||
if *updatePartyGolden {
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log("party golden rewritten:", path)
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read party golden (run with -update-party to create it): %v", err)
|
||||
}
|
||||
if string(want) != got {
|
||||
t.Fatalf("PARTY ENGINE BEHAVIOUR MOVED.\n\n%s\n\n"+
|
||||
"If this was deliberate, say so in the commit and regenerate:\n"+
|
||||
" go test ./internal/plugin -run TestPartyCharacterization -update-party",
|
||||
firstDiff(string(want), got))
|
||||
}
|
||||
}
|
||||
|
||||
// The one-seat roster is the solo engine. If this ever fails, the N-body path has
|
||||
// stopped being a superset of the path the entire balance corpus was measured on,
|
||||
// and every baseline in the repo is suspect.
|
||||
func TestPartyCharacterization_OneSeatIsStillSolo(t *testing.T) {
|
||||
for _, seed := range charSeeds {
|
||||
solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases,
|
||||
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
|
||||
party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases,
|
||||
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
|
||||
|
||||
if solo.PlayerWon != party.PlayerWon || solo.TotalRounds != party.TotalRounds ||
|
||||
solo.EnemyEndHP != party.EnemyEndHP || solo.PlayerEndHP != party.Seats[0].PlayerEndHP {
|
||||
t.Fatalf("seed %d: a one-seat roster diverged from solo\n solo: won=%v rounds=%d ehp=%d php=%d\n party: won=%v rounds=%d ehp=%d php=%d",
|
||||
seed,
|
||||
solo.PlayerWon, solo.TotalRounds, solo.EnemyEndHP, solo.PlayerEndHP,
|
||||
party.PlayerWon, party.TotalRounds, party.EnemyEndHP, party.Seats[0].PlayerEndHP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,18 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||
for seat := range sess.RosterSize() {
|
||||
// The companion has no sheet, so none of the sheet-keyed bookkeeping below
|
||||
// applies to him — and postCombatBookkeepingForSeat logs at ERROR for a
|
||||
// seat with no sheet, which would file one for every party fight he is ever
|
||||
// hired for. But his HP is not bookkeeping: it is the fight's result. It
|
||||
// lands on his roster row, because that is the only row he has.
|
||||
//
|
||||
// He used to be skipped outright, and "he arrives fresh next time" was the
|
||||
// stated intent. It is a free lunch — see companionSeatHP.
|
||||
if isCompanionUser(sess.seatUserID(seat)) {
|
||||
_ = setCompanionHPForRun(sess.RunID, sess.seatHP(seat))
|
||||
continue
|
||||
}
|
||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||
p.postCombatBookkeepingForSeat(sess, seat)
|
||||
}
|
||||
@@ -100,6 +112,16 @@ func (p *AdventurePlugin) finishPartyWin(
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
|
||||
// The hired companion takes no cut. He earns no XP (there is no sheet to
|
||||
// put it on), rolls no loot (dropZoneLoot writes real inventory rows for
|
||||
// whatever id it is handed, and a bot with a magic sword is nobody's
|
||||
// intent), and takes no death row. His seat renders nothing: he is not
|
||||
// reading this. He was paid up front.
|
||||
if isCompanionSeat(uid) {
|
||||
out[seat] = ""
|
||||
continue
|
||||
}
|
||||
|
||||
// A member who went down before the killing blow still earns the kill.
|
||||
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
||||
|
||||
@@ -118,6 +140,9 @@ func (p *AdventurePlugin) finishPartyWin(
|
||||
b.WriteString(drop + "\n")
|
||||
}
|
||||
}
|
||||
if !elite {
|
||||
writeBossEpilogue(&b, zone.ID)
|
||||
}
|
||||
switch {
|
||||
case bossOnExpedition && seat == 0:
|
||||
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
|
||||
@@ -145,7 +170,17 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
|
||||
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
||||
out := make([]string, sess.RosterSize())
|
||||
for seat := range sess.RosterSize() {
|
||||
markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display)
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
// The companion does not die. markAdventureDead is a silent no-op for him
|
||||
// today (no player_meta row to mark), but relying on that accident is how
|
||||
// he ends up in the graveyard the first time someone gives him a row —
|
||||
// and emitDeathNews would have the news bot file a death notice about
|
||||
// itself. Say it out loud instead.
|
||||
if isCompanionSeat(uid) {
|
||||
out[seat] = ""
|
||||
continue
|
||||
}
|
||||
markAdventureDead(uid, "zone", zone.Display)
|
||||
var b strings.Builder
|
||||
if line != "" && seat == 0 {
|
||||
b.WriteString(line + "\n")
|
||||
@@ -217,9 +252,9 @@ func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
||||
}
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
reason := "combat flee"
|
||||
reason := lossCombatFlee
|
||||
if death {
|
||||
reason = "combat death"
|
||||
reason = lossCombatDeath
|
||||
}
|
||||
forceExtractExpeditionForRunLoss(owner, reason)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ func fightRoster(sender id.UserID) []id.UserID {
|
||||
if err != nil || e == nil {
|
||||
return []id.UserID{sender}
|
||||
}
|
||||
return expeditionAudience(e)
|
||||
// Seats, not audience: the hired companion fights even though he never
|
||||
// receives a DM about it.
|
||||
return expeditionSeats(e)
|
||||
}
|
||||
|
||||
// buildFightSeats turns a roster into the seats that will actually sit down, and
|
||||
@@ -56,9 +58,40 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
senderSkip = why
|
||||
}
|
||||
}
|
||||
// Parallel to `seats`, so a skipped member leaves no gap: what a seat costs the
|
||||
// enemy is priced from these once the roster is final.
|
||||
var levels []int
|
||||
var companions []bool
|
||||
for i, uid := range roster {
|
||||
leader := i == 0
|
||||
|
||||
// The hired companion. He must be handled before everything below:
|
||||
// dndHPSnapshot returns (0,0) for a user with no sheet, so the very next
|
||||
// check would quietly sit him out of every fight he was paid for, and
|
||||
// buildZoneCombatants would then fail on him anyway.
|
||||
//
|
||||
// He is latched onto autopilot at seat time rather than after the away-player
|
||||
// deadline — nobody is going to type for him, and waiting three minutes to
|
||||
// discover that would stall the fight and then announce him to the party as
|
||||
// an absent player.
|
||||
if isCompanionSeat(uid) {
|
||||
expID := companionExpeditionFor(roster[0])
|
||||
class, level := companionLoadout(expID)
|
||||
player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
||||
// He carries his wounds between fights, like everyone else. Seating him at
|
||||
// full max HP — which is what this did — hands the party an infinite body:
|
||||
// he soaks a share of every fight's incoming and then resets, while the
|
||||
// humans beside him bleed all the way to camp.
|
||||
seats = append(seats, CombatSeatSetup{
|
||||
UserID: uid,
|
||||
HP: companionSeatHP(expID, player.Stats.MaxHP),
|
||||
HPMax: player.Stats.MaxHP,
|
||||
Mods: player.Mods, C: &player, EngineDriven: true,
|
||||
})
|
||||
levels, companions = append(levels, level), append(companions, true)
|
||||
continue
|
||||
}
|
||||
|
||||
// Both refusals below are cheap and neither needs the build, so they run
|
||||
// before it: consuming a seat's armed ability and *then* sitting them out
|
||||
// would spend their rage on a fight they never joined.
|
||||
@@ -94,7 +127,7 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
trySimAutoArm(c)
|
||||
armed = consumeArmedAbility(c)
|
||||
}
|
||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
||||
player, e, dc, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
||||
if err != nil {
|
||||
if leader {
|
||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||
@@ -113,7 +146,17 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
seats = append(seats, CombatSeatSetup{
|
||||
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
||||
})
|
||||
lvl := 0
|
||||
if dc != nil {
|
||||
lvl = dc.Level
|
||||
}
|
||||
levels, companions = append(levels, lvl), append(companions, false)
|
||||
}
|
||||
|
||||
// Price each seat against the leader. It runs here, over the seats that were
|
||||
// actually seated — a member who was skipped (downed, busy elsewhere) never
|
||||
// joined the fight and must not be charged to the enemy.
|
||||
applySeatWeights(seatCombatants(seats), levels, companions)
|
||||
return seats, enemy, senderSkip, ""
|
||||
}
|
||||
|
||||
@@ -159,6 +202,12 @@ func (p *AdventurePlugin) announcePartyFightStart(
|
||||
names[i] = c.Name
|
||||
}
|
||||
for seat, uid := range sess.SeatUserIDs() {
|
||||
// Seat-keyed fan-out, so it bypasses expeditionAudience's filter — the
|
||||
// companion sits down but is never written to. (He also has no magic items
|
||||
// to line up, and activeMagicItemsLine would go looking for them.)
|
||||
if isCompanionUser(uid) {
|
||||
continue
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(header)
|
||||
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))
|
||||
|
||||
@@ -166,6 +166,15 @@ func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (
|
||||
return fail(notYourTurnMsg(players, acting, waiting))
|
||||
}
|
||||
|
||||
// An engine-driven seat has nobody to hand the wheel back to. A command
|
||||
// arriving from one is not a player returning to the keyboard — it is a driver
|
||||
// impersonating a seat, which is precisely the confusion that used to strand
|
||||
// the companion: his own auto-played turn came back through here looking like
|
||||
// a keystroke and cleared the latch that was moving him. Refuse it outright.
|
||||
if sess.seatIsEngineDriven(seat) {
|
||||
return fail("That seat isn't yours to play.")
|
||||
}
|
||||
|
||||
// They typed, so they are here. Hand back the wheel.
|
||||
sess.actorStatusesPtr(seat).Autopilot = false
|
||||
|
||||
@@ -315,6 +324,33 @@ func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
|
||||
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
|
||||
}
|
||||
|
||||
// driveEngineSeat plays one engine-driven seat's turn and persists the result.
|
||||
// It is the seat-driver equivalent of a player typing `!attack`, for a seat that
|
||||
// will never type anything.
|
||||
//
|
||||
// It exists because the alternative — dispatching a combat command as that seat —
|
||||
// sends the turn back through beginCombatTurn, which reads any command from a
|
||||
// seat as "that player is back". For a human that is correct. For a seat with no
|
||||
// human it is fatal: it drops the latch that is the only thing moving them.
|
||||
func (p *AdventurePlugin) driveEngineSeat(sess *CombatSession, seat int) error {
|
||||
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rebuild for engine seat: %w", err)
|
||||
}
|
||||
ct := &combatTurn{
|
||||
sess: sess, players: players, enemy: enemy,
|
||||
seat: seat, uid: id.UserID(sess.seatUserID(seat)),
|
||||
}
|
||||
if _, err := p.runAutoSeatTurn(ct, seat); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveCombatSession(sess); err != nil {
|
||||
return fmt.Errorf("save after engine seat turn: %w", err)
|
||||
}
|
||||
p.closePartyRound(ct)
|
||||
return nil
|
||||
}
|
||||
|
||||
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
|
||||
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
|
||||
// save that parked the fight on this seat's player_turn was the last one — so it
|
||||
@@ -387,6 +423,12 @@ func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEven
|
||||
names := ct.seatNames()
|
||||
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
||||
for seat, uid := range ct.sess.SeatUserIDs() {
|
||||
// The combat fan-out is seat-keyed, so it does not pass through
|
||||
// expeditionAudience and does not inherit its companion filter. He fights;
|
||||
// he is not written to about it.
|
||||
if isCompanionUser(uid) {
|
||||
continue
|
||||
}
|
||||
// Rendered once per reader: the flavor pool speaks in the second person,
|
||||
// so each member's own events must be theirs and nobody else's.
|
||||
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
|
||||
|
||||
174
internal/plugin/combat_seat_spells.go
Normal file
174
internal/plugin/combat_seat_spells.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Seat-scoped spellbook.
|
||||
//
|
||||
// Every spell lookup in the engine — known list, prepared flag, slot pool, slot
|
||||
// spend — is keyed on a Matrix user id and answered by a `dnd_*` table. That was
|
||||
// fine while every combatant was a player. The hired companion is not: he has no
|
||||
// row in dnd_character, dnd_known_spells or dnd_spell_slots, deliberately, because
|
||||
// a sheet on disk for him is the thing that would turn him into a real character
|
||||
// everywhere (player_meta, the leaderboard, !stats). His sheet is synthesized per
|
||||
// fight and thrown away.
|
||||
//
|
||||
// So every one of those lookups returned "nothing" for him, and the picker's very
|
||||
// first line — `c, _ := LoadDnDCharacter(uid); if c == nil { return "attack" }` —
|
||||
// sent him to swing a mace, every turn, forever. A hired Cleric could not heal.
|
||||
// Role-fill hands a lone fighter a Cleric, so that was the *common* case.
|
||||
//
|
||||
// These are the seat-scoped forms of those lookups. A human seat delegates to the
|
||||
// DB functions verbatim — same queries, same order, so solo combat and the balance
|
||||
// corpus are untouched. A companion seat is answered from his in-memory sheet and
|
||||
// a slot ledger on his seat's persisted statuses.
|
||||
//
|
||||
// Nothing here may write a row for the companion. That invariant is what
|
||||
// TestCompanion_SheetIsBelowMedianAndNeverPersisted pins, and the auto-migration
|
||||
// inside ensureCharForDnDCmd would violate it silently: handed a user with no
|
||||
// sheet, it *builds one at level 1 and saves it*. Hence seatCastSheet.
|
||||
|
||||
// seatCompanionLoadout returns the class and level a companion seat fights as,
|
||||
// and whether the seat is the companion at all.
|
||||
func seatCompanionLoadout(sess *CombatSession, uid id.UserID) (DnDClass, int, bool) {
|
||||
if sess == nil || !isCompanionSeat(uid) {
|
||||
return "", 0, false
|
||||
}
|
||||
class, level := companionLoadoutForRun(sess.RunID)
|
||||
return class, level, true
|
||||
}
|
||||
|
||||
// seatCastSheet resolves the character behind a seat for the !cast path.
|
||||
//
|
||||
// A human goes through ensureCharForDnDCmd, exactly as before — including its
|
||||
// auto-migration for a player who predates Adv 2.0. The companion must NOT: that
|
||||
// migration would mint and persist a level-1 dnd_character row for him, quietly
|
||||
// making him a player and throwing away the class and level he was hired at. He
|
||||
// gets his synthetic sheet instead, built from the same class-priority pipeline a
|
||||
// real character of his level uses.
|
||||
func (p *AdventurePlugin) seatCastSheet(sess *CombatSession, uid id.UserID) (*DnDCharacter, error) {
|
||||
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||
return companionSheet(class, level), nil
|
||||
}
|
||||
advChar, _ := loadAdvCharacter(uid)
|
||||
return p.ensureCharForDnDCmd(uid, advChar)
|
||||
}
|
||||
|
||||
// seatPickSheet is seatCastSheet for the auto-picker, which reads a sheet to
|
||||
// decide a turn and must never create one. Humans get LoadDnDCharacter, which is
|
||||
// what the picker has always called; a miss returns nil and the caller swings.
|
||||
func seatPickSheet(sess *CombatSession, uid id.UserID) *DnDCharacter {
|
||||
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||
return companionSheet(class, level)
|
||||
}
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
return c
|
||||
}
|
||||
|
||||
// companionKnownSpells is the companion's spell list: the same default kit
|
||||
// ensureSpellsForCharacter grants a real character of that class and level, every
|
||||
// entry prepared (which is also what that function does — preparation is SP4 and
|
||||
// until it lands every granted spell is auto-prepared so !cast works).
|
||||
//
|
||||
// He gets the player kit on purpose. His below-median comes from the level penalty,
|
||||
// the never-Masterwork gear and the absent subclass/magic items — the layers a
|
||||
// player accumulates. Handing him a bespoke, weaker spell list would be a second
|
||||
// nerf hidden in a different file, and it would drift away from the tuned list the
|
||||
// moment anyone touched one and not the other.
|
||||
func companionKnownSpells(class DnDClass, level int) []knownSpellRow {
|
||||
ids := defaultKnownSpells(class, level)
|
||||
out := make([]knownSpellRow, 0, len(ids))
|
||||
for _, sid := range ids {
|
||||
out = append(out, knownSpellRow{SpellID: sid, Source: "companion", Prepared: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// companionSlotPool is his slot table (total, used) — the class/level progression
|
||||
// every caster shares, less what he has already spent. `used` is the expedition's
|
||||
// ledger, NOT a per-fight one: he rations one pool across the run and gets it back
|
||||
// at camp, exactly as a human caster does.
|
||||
func companionSlotPool(class DnDClass, level int, used [6]int) map[int][2]int {
|
||||
out := map[int][2]int{}
|
||||
for lvl, total := range slotsForClassLevel(class, level) {
|
||||
spent := 0
|
||||
if lvl >= 0 && lvl < len(used) {
|
||||
spent = min(used[lvl], total)
|
||||
}
|
||||
out[lvl] = [2]int{total, spent}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seatKnownSpells is listKnownSpells for a seat.
|
||||
func seatKnownSpells(sess *CombatSession, seat int, uid id.UserID) ([]knownSpellRow, error) {
|
||||
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||
return companionKnownSpells(class, level), nil
|
||||
}
|
||||
return listKnownSpells(uid)
|
||||
}
|
||||
|
||||
// seatSpellSlots is getSpellSlots for a seat.
|
||||
func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) {
|
||||
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||
return companionSlotPool(class, level, companionSlotsForRun(sess.RunID)), nil
|
||||
}
|
||||
return getSpellSlots(uid)
|
||||
}
|
||||
|
||||
// seatKnowsSpell is playerKnowsSpell for a seat: (known, prepared, err).
|
||||
func seatKnowsSpell(sess *CombatSession, seat int, uid id.UserID, spellID string) (bool, bool, error) {
|
||||
if _, _, ok := seatCompanionLoadout(sess, uid); ok {
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
for _, k := range known {
|
||||
if k.SpellID == spellID {
|
||||
return true, k.Prepared, nil
|
||||
}
|
||||
}
|
||||
return false, false, nil
|
||||
}
|
||||
return playerKnowsSpell(uid, spellID)
|
||||
}
|
||||
|
||||
// consumeSeatSlot is consumeSpellSlot for a seat. The companion's spend lands on
|
||||
// the expedition's ledger — one pool for the whole run, refilled at camp — so he
|
||||
// rations his slots the way a human caster has to. Keying it by expedition also
|
||||
// keeps two parties who have each hired him from sharing a pool, which anything
|
||||
// keyed on his (single, shared) user id would have done.
|
||||
func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) {
|
||||
class, level, ok := seatCompanionLoadout(sess, uid)
|
||||
if !ok {
|
||||
return consumeSpellSlot(uid, slotLevel)
|
||||
}
|
||||
used := companionSlotsForRun(sess.RunID)
|
||||
if slotLevel < 1 || slotLevel >= len(used) {
|
||||
return false, nil
|
||||
}
|
||||
pair, exists := companionSlotPool(class, level, used)[slotLevel]
|
||||
if !exists || pair[0]-pair[1] <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
used[slotLevel]++
|
||||
if err := setCompanionSlotsForRun(sess.RunID, used); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// refundSeatSlot is refundSpellSlot for a seat: the rollback half of the above,
|
||||
// called when the round the slot was spent on failed to resolve.
|
||||
func refundSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) error {
|
||||
if _, _, ok := seatCompanionLoadout(sess, uid); !ok {
|
||||
return refundSpellSlot(uid, slotLevel)
|
||||
}
|
||||
used := companionSlotsForRun(sess.RunID)
|
||||
if slotLevel < 1 || slotLevel >= len(used) || used[slotLevel] <= 0 {
|
||||
return nil
|
||||
}
|
||||
used[slotLevel]--
|
||||
return setCompanionSlotsForRun(sess.RunID, used)
|
||||
}
|
||||
277
internal/plugin/combat_seat_spells_test.go
Normal file
277
internal/plugin/combat_seat_spells_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The hired companion casts.
|
||||
//
|
||||
// He could not, and the reason was one line: every spell lookup in the engine is
|
||||
// keyed on a user id and answered by a dnd_* table, and he has no rows in any of
|
||||
// them — deliberately, because a sheet on disk is what would turn him into a real
|
||||
// character everywhere. So the picker's first statement (`LoadDnDCharacter(uid)`)
|
||||
// came back nil and returned "attack", every turn, for the whole fight.
|
||||
//
|
||||
// Role-fill hands a lone martial a Cleric. So the common case of the feature was a
|
||||
// healer who could not heal, in a party engine that had only just learned to let
|
||||
// anyone heal anyone (§1). These pin both halves: that he picks the heal, and that
|
||||
// picking it leaves no trace of him in the database.
|
||||
|
||||
// hireForFight seeds an expedition with the companion hired into it, and returns
|
||||
// the run id his loadout is resolved against.
|
||||
func hireForFight(t *testing.T, expID string, owner id.UserID, class DnDClass, level int) string {
|
||||
t.Helper()
|
||||
seedExpedition(t, expID, owner, "active")
|
||||
seatLeaderFixture(t, expID)
|
||||
if err := hireCompanion(expID, class, level); err != nil {
|
||||
t.Fatalf("hireCompanion: %v", err)
|
||||
}
|
||||
return "run-" + expID
|
||||
}
|
||||
|
||||
// petePartyFight seats a hurt leader and the companion, and returns the turn.
|
||||
func petePartyFight(t *testing.T, p *AdventurePlugin, runID string, leader id.UserID, leaderHP int) *combatTurn {
|
||||
t.Helper()
|
||||
monster := dndBestiary["goblin"]
|
||||
lead, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
|
||||
lead.Name = "lead"
|
||||
pete, _, _ := p.companionCombatant(ClassCleric, 6, monster, 2, 0)
|
||||
pete.Name = companionDisplayName
|
||||
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
|
||||
|
||||
sess, err := p.startPartyCombatSession(runID, "enc", "goblin", &enemy, []CombatSeatSetup{
|
||||
{UserID: leader, HP: leaderHP, HPMax: 100, Mods: lead.Mods, C: &lead},
|
||||
{UserID: companionUserID(), HP: 60, HPMax: 60, Mods: pete.Mods, C: &pete, EngineDriven: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &combatTurn{
|
||||
sess: sess, players: []*Combatant{&lead, &pete}, enemy: &enemy,
|
||||
seat: 1, uid: companionUserID(),
|
||||
}
|
||||
}
|
||||
|
||||
// The headline: a hired Cleric, watching the leader bleed out, casts a heal on him.
|
||||
// Before the seat-scoped spellbook this returned ("attack", "") — he swung a mace
|
||||
// at the boss while the man who paid for him died.
|
||||
func TestCompanionSpells_HiredClericHealsTheLeader(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
leader := id.UserID("@lead:example.org")
|
||||
runID := hireForFight(t, "exp-heal", leader, ClassCleric, 6)
|
||||
ct := petePartyFight(t, p, runID, leader, 20) // leader at 20/100 — well under the 45% bar
|
||||
|
||||
kind, arg := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
|
||||
if kind != "cast" {
|
||||
t.Fatalf("the hired cleric picked %q %q with the leader at 20%% HP — he must heal", kind, arg)
|
||||
}
|
||||
if !strings.Contains(arg, "@lead") {
|
||||
t.Fatalf("cast arg = %q, want the heal aimed at the leader's seat", arg)
|
||||
}
|
||||
|
||||
// And it lands on the leader, not on himself.
|
||||
action, settle, msg := p.castActionForSeat(ct, 1, arg)
|
||||
if msg != "" {
|
||||
t.Fatalf("castActionForSeat refused the companion's own pick: %s", msg)
|
||||
}
|
||||
settle(true)
|
||||
eff := action.Effect
|
||||
if eff == nil || eff.AllyHeal <= 0 {
|
||||
t.Fatalf("effect = %+v, want an ally heal", eff)
|
||||
}
|
||||
if eff.AllySeat != 0 {
|
||||
t.Errorf("heal landed on seat %d, want seat 0 (the leader)", eff.AllySeat)
|
||||
}
|
||||
if eff.PlayerHeal != 0 {
|
||||
t.Errorf("the heal also healed the caster (%d HP) — it was redirected, not copied", eff.PlayerHeal)
|
||||
}
|
||||
}
|
||||
|
||||
// His slots are spent on his seat, and he leaves no rows behind.
|
||||
//
|
||||
// This is the invariant with teeth. castActionForSeat used to load the caster via
|
||||
// ensureCharForDnDCmd, whose auto-migration branch — handed a user with no sheet —
|
||||
// *builds one at level 1 and saves it*. Pointed at the companion that silently
|
||||
// makes him a player: a dnd_character row, a player_meta seed, a spellbook, and a
|
||||
// level-1 chassis in place of the level he was hired at.
|
||||
func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
leader := id.UserID("@lead:example.org")
|
||||
runID := hireForFight(t, "exp-rows", leader, ClassCleric, 6)
|
||||
ct := petePartyFight(t, p, runID, leader, 20)
|
||||
|
||||
action, settle, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead")
|
||||
if msg != "" {
|
||||
t.Fatalf("the hired cleric could not cast cure wounds: %s", msg)
|
||||
}
|
||||
settle(true)
|
||||
if action.Effect == nil || action.Effect.AllyHeal <= 0 {
|
||||
t.Fatalf("effect = %+v, want an ally heal", action.Effect)
|
||||
}
|
||||
|
||||
// The slot came off the expedition's ledger — which is where it has to live, so
|
||||
// that it is still spent in the NEXT fight of the same run.
|
||||
if used := companionSlotsForRun(ct.sess.RunID); used[1] != 1 {
|
||||
t.Errorf("companion spent %v level-1 slots on the run's ledger, want 1", used[1])
|
||||
}
|
||||
|
||||
for _, table := range []string{"dnd_character", "dnd_known_spells", "dnd_spell_slots", "player_meta"} {
|
||||
var n int
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM `+table+` WHERE user_id = ?`, string(companionUserID()),
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("casting wrote %d %s row(s) for the companion — he is not a player", n, table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// He runs dry like anybody else, and then he swings. A companion with an infinite
|
||||
// spell pool is the "carry" the whole design says he must never be.
|
||||
func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
leader := id.UserID("@lead:example.org")
|
||||
runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6)
|
||||
ct := petePartyFight(t, p, runID, leader, 20)
|
||||
|
||||
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
|
||||
if total <= 0 {
|
||||
t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve")
|
||||
}
|
||||
for i := range total {
|
||||
if ok, err := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil || !ok {
|
||||
t.Fatalf("slot %d/%d: consume = %v (%v), want it to succeed", i+1, total, ok, err)
|
||||
}
|
||||
}
|
||||
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); ok {
|
||||
t.Fatalf("the companion cast a %d-th level-1 spell out of a %d-slot pool", total+1, total)
|
||||
}
|
||||
|
||||
// The refund half: a round that fails to resolve gives the slot back.
|
||||
if err := refundSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); !ok {
|
||||
t.Error("a refunded slot was not castable again")
|
||||
}
|
||||
}
|
||||
|
||||
// His pool does NOT refill between fights, and it DOES come back at camp.
|
||||
//
|
||||
// This is the one the sweep caught and the unit tests did not. The first cut of
|
||||
// the spellbook parked his ledger on his combat seat — and a seat is per-session,
|
||||
// so every fight opened a fresh one and he walked in with full slots. A human
|
||||
// cleric rations a single pool across the whole run; rationing it IS the caster's
|
||||
// game. Handed an infinite pool, a gearless level-penalized hireling out-cleared a
|
||||
// same-level human cleric by 15pp in the sim.
|
||||
func TestCompanionSpells_PoolIsRationedAcrossTheRun(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
leader := id.UserID("@lead:example.org")
|
||||
runID := hireForFight(t, "exp-ration", leader, ClassCleric, 6)
|
||||
|
||||
// Fight one: spend every level-1 slot he owns.
|
||||
first := petePartyFight(t, p, runID, leader, 20)
|
||||
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
|
||||
for range total {
|
||||
if ok, err := consumeSeatSlot(first.sess, 1, companionUserID(), 1); err != nil || !ok {
|
||||
t.Fatalf("consume: %v (%v)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fight two, same run — a NEW combat session, which is exactly what used to
|
||||
// hand him a fresh pool.
|
||||
if err := markCombatSessionExpired(first.sess.SessionID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := petePartyFight(t, p, runID, leader, 20)
|
||||
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); ok {
|
||||
t.Error("the companion's spell slots refilled between fights — he is an infinite caster")
|
||||
}
|
||||
|
||||
// ...and camp gives them back, the same way it does for every human.
|
||||
if err := refreshCompanionSlots("exp-ration"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); !ok {
|
||||
t.Error("camp did not restore the companion's slots — his pool only ever goes down")
|
||||
}
|
||||
}
|
||||
|
||||
// He carries his wounds between fights, and camp patches him up.
|
||||
//
|
||||
// He used to re-seat at full max HP for every single fight — "he arrives fresh
|
||||
// next time" was the close-out's stated intent — which is an infinite body. A
|
||||
// player bleeds across a 30-room run and only heals at camp; the hireling soaked
|
||||
// his share of every fight and then reset. In the sim his party fled 5 runs out of
|
||||
// 640 where the same party with a *human* cleric fled 56.
|
||||
func TestCompanion_CarriesWoundsBetweenFights(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
seedExpedition(t, "exp-hp", leader, "active")
|
||||
seatLeaderFixture(t, "exp-hp")
|
||||
if err := hireCompanion("exp-hp", ClassCleric, 6); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A fresh hire is at full.
|
||||
if got := companionSeatHP("exp-hp", 100); got != 100 {
|
||||
t.Errorf("a fresh hire seats at %d/100 HP, want full", got)
|
||||
}
|
||||
|
||||
// He walks out of a fight on 30 HP; he walks into the next one on 30 HP.
|
||||
if err := setCompanionHP("exp-hp", 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := companionSeatHP("exp-hp", 100); got != 30 {
|
||||
t.Errorf("he re-seated at %d/100 HP after ending a fight on 30 — the wound did not carry", got)
|
||||
}
|
||||
|
||||
// Dropped in a fight, he comes back on his feet but barely — not as a corpse
|
||||
// (there is no companion-death rule) and not at full.
|
||||
if err := setCompanionHP("exp-hp", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := companionSeatHP("exp-hp", 100); got != 1 {
|
||||
t.Errorf("after being dropped he seats at %d/100 HP, want 1", got)
|
||||
}
|
||||
|
||||
// Camp puts him right, exactly as it does every human.
|
||||
if err := refreshCompanionHP("exp-hp"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := companionSeatHP("exp-hp", 100); got != 100 {
|
||||
t.Errorf("camp left him on %d/100 HP — his body only ever goes down", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A hired martial is still a martial: the spellbook is the class's, not a blanket
|
||||
// grant, so hiring a Fighter does not quietly buy a caster.
|
||||
func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
leader := id.UserID("@lead:example.org")
|
||||
runID := hireForFight(t, "exp-mart", leader, ClassFighter, 6)
|
||||
ct := petePartyFight(t, p, runID, leader, 20)
|
||||
|
||||
if known, _ := seatKnownSpells(ct.sess, 1, companionUserID()); len(known) != 0 {
|
||||
t.Errorf("a hired Fighter knows %d spells, want none", len(known))
|
||||
}
|
||||
if _, _, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead"); msg == "" {
|
||||
t.Error("a hired Fighter cast cure wounds")
|
||||
}
|
||||
kind, _ := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
|
||||
if kind != "attack" {
|
||||
t.Errorf("a hired Fighter picked %q, want attack", kind)
|
||||
}
|
||||
}
|
||||
108
internal/plugin/combat_seat_weight_test.go
Normal file
108
internal/plugin/combat_seat_weight_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// §2(a) — the enemy charges for what a seat BRINGS, not for the fact that it sat
|
||||
// down.
|
||||
//
|
||||
// Both scaling levers (the +15% boss HP and the 1 → 2.4 attacks-a-round action
|
||||
// economy) counted seats. A seat count charges the same for an under-levelled
|
||||
// friend, a hired NPC, and a true peer — so a below-median body cost a full seat's
|
||||
// worth of boss and did not give a full seat's worth back. Measured: hiring the
|
||||
// companion was *worse than going alone* (66.1% against solo's 69.0%) once his
|
||||
// free full-heal was taken away.
|
||||
//
|
||||
// The invariant that makes this safe to ship: every seat that IS a peer still
|
||||
// weighs exactly 1.0, so solo and a party of equals are byte-identical and the
|
||||
// balance corpus does not move. Only an unequal roster lands between the knots.
|
||||
|
||||
func TestSeatWeight_APeerWeighsExactlyOne(t *testing.T) {
|
||||
// The leader, and a friend of the leader's level: both full price.
|
||||
if got := seatWeight(10, 10, false); got != 1.0 {
|
||||
t.Errorf("a peer weighs %v, want exactly 1.0 — a party of equals must not move", got)
|
||||
}
|
||||
// Out-levelling the leader does not make the boss harder for everybody else.
|
||||
if got := seatWeight(14, 10, false); got != 1.0 {
|
||||
t.Errorf("a higher-level friend weighs %v, want 1.0 (capped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeatWeight_TheUnderLevelledAndTheHiredCostLess(t *testing.T) {
|
||||
// The under-levelled friend — the case that has nothing to do with the
|
||||
// companion and has been live since parties shipped.
|
||||
half := seatWeight(5, 10, false)
|
||||
if half >= 1.0 || half <= seatWeightFloor {
|
||||
t.Errorf("a level-5 friend of a level-10 leader weighs %v, want between the floor and 1.0", half)
|
||||
}
|
||||
// The hireling pays the same level penalty AND the discount for everything a
|
||||
// player accrues that he never will (subclass, magic items, Masterwork gear).
|
||||
hired := seatWeight(9, 10, true)
|
||||
peerAtSameLevel := seatWeight(9, 10, false)
|
||||
if hired >= peerAtSameLevel {
|
||||
t.Errorf("the hireling weighs %v and a human of his level weighs %v — he must cost the enemy less",
|
||||
hired, peerAtSameLevel)
|
||||
}
|
||||
// But a body is never free: it is still one more thing the boss has to kill.
|
||||
if got := seatWeight(1, 20, true); got < seatWeightFloor {
|
||||
t.Errorf("a hopelessly under-levelled hireling weighs %v, below the floor %v", got, seatWeightFloor)
|
||||
}
|
||||
}
|
||||
|
||||
// The levers themselves: integer knots byte-exact, fractions in between.
|
||||
func TestSeatWeight_ScalingIsExactAtThePeerKnots(t *testing.T) {
|
||||
// Solo and a party of peers reproduce the pre-§2(a) numbers exactly. This is
|
||||
// the whole safety argument — if either of these drifts, the corpus is invalid.
|
||||
for _, tc := range []struct {
|
||||
weight float64
|
||||
acts float64
|
||||
hp float64
|
||||
}{
|
||||
{1, 1, 1.0}, // solo
|
||||
{2, 2.4, 1.15}, // a duo of peers
|
||||
{3, 5, 1.15}, // a trio of peers
|
||||
} {
|
||||
if got := partyActionExpectation(tc.weight); got != tc.acts {
|
||||
t.Errorf("weight %v: enemy actions = %v, want exactly %v", tc.weight, got, tc.acts)
|
||||
}
|
||||
if got := partyEnemyHPScale(tc.weight); got != tc.hp {
|
||||
t.Errorf("weight %v: enemy HP scale = %v, want exactly %v", tc.weight, got, tc.hp)
|
||||
}
|
||||
}
|
||||
|
||||
// A leader plus a hireling is strictly cheaper than a leader plus a peer, and
|
||||
// strictly dearer than soloing. Bringing him must cost the boss *something* —
|
||||
// that is what stops a free body from becoming a carry.
|
||||
duoWithHire := 1 + seatWeight(9, 10, true)
|
||||
if a := partyActionExpectation(duoWithHire); a <= 1 || a >= 2.4 {
|
||||
t.Errorf("leader + hireling buys the enemy %v actions, want strictly between 1 and 2.4", a)
|
||||
}
|
||||
if h := partyEnemyHPScale(duoWithHire); h <= 1.0 || h >= 1.15 {
|
||||
t.Errorf("leader + hireling scales boss HP by %v, want strictly between 1.0 and 1.15", h)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat that is DOWN buys the enemy nothing. §2(b) fixed the head-count half of
|
||||
// this; the weight half has to hold too, or a corpse keeps paying for swings.
|
||||
func TestSeatWeight_TheDeadBuyTheEnemyNothing(t *testing.T) {
|
||||
alive := &Combatant{SeatWeight: 1}
|
||||
hire := &Combatant{SeatWeight: 0.6}
|
||||
st := &combatState{actors: []*actor{
|
||||
{c: alive, playerHP: 40},
|
||||
{c: hire, playerHP: 0}, // dropped
|
||||
}}
|
||||
if got := livingWeight(st); got != 1 {
|
||||
t.Errorf("living weight with a downed hireling = %v, want 1 (only the survivor pays)", got)
|
||||
}
|
||||
st.actors[1].playerHP = 20
|
||||
if got := livingWeight(st); got != 1.6 {
|
||||
t.Errorf("living weight with the hireling up = %v, want 1.6", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An unset weight reads as a full peer, so every combatant built before this
|
||||
// existed — and every test that builds one by hand — is priced exactly as before.
|
||||
func TestSeatWeight_UnsetIsAPeer(t *testing.T) {
|
||||
if got := combatantWeight(&Combatant{}); got != 1 {
|
||||
t.Errorf("a combatant with no weight set counts %v, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ type ActorStatuses struct {
|
||||
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
|
||||
// Without the latch an away member taxes the party the full deadline every
|
||||
// single round; with it, they cost one wait and then resolve instantly. Any
|
||||
// combat command from that member clears it.
|
||||
// combat command from that member clears it — they typed, so they are back.
|
||||
//
|
||||
// Like the Buff* deltas it is session-layer state with no combatState
|
||||
// counterpart, so snapshotActor carries it over from the prior snapshot
|
||||
@@ -129,6 +129,24 @@ type ActorStatuses struct {
|
||||
// fight has no turn deadline, only the 1h session reaper.
|
||||
Autopilot bool `json:"autopilot,omitempty"`
|
||||
|
||||
// EngineDriven marks a seat that has NO human behind it and never will: a
|
||||
// hired companion today, an NPC ally or a Pete-led expedition tomorrow. It is
|
||||
// the answer to "who owns this turn", and it is deliberately NOT the same
|
||||
// question as Autopilot.
|
||||
//
|
||||
// Autopilot means "a human is away"; it is provisional, and a keystroke ends
|
||||
// it. EngineDriven means "there is nobody to come back", and nothing clears it.
|
||||
// Collapsing the two is a bug with teeth: the expedition autopilot drives a
|
||||
// party by dispatching each seat's turn as a command from that seat, so an
|
||||
// engine seat's own auto-played move arrived back at beginCombatTurn looking
|
||||
// exactly like a player returning to the keyboard, and cleared the very latch
|
||||
// that was moving it. The seat then stood in the fight doing nothing for the
|
||||
// rest of the fight — while the enemy it had inflated by 15% killed the party.
|
||||
//
|
||||
// So the property lives on the seat, not on an identity check, and no command
|
||||
// path can unset it.
|
||||
EngineDriven bool `json:"engine_driven,omitempty"`
|
||||
|
||||
// Debuffs the enemy has stacked onto this character specifically.
|
||||
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||
@@ -256,7 +274,8 @@ func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||
st.HealChargesLeft = playerMods.HealItemCharges
|
||||
st.ArmedAbility = seat.ArmedAbility
|
||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||
st.EngineDriven = seat.EngineDriven
|
||||
return st.EngineDriven || st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
||||
st.ArmedAbility != ""
|
||||
}
|
||||
@@ -387,16 +406,30 @@ func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
|
||||
// turn silently rather than blocking the round on a corpse.
|
||||
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
||||
|
||||
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
|
||||
// by a lapsed turn deadline.
|
||||
// seatIsEngineDriven reports whether a seat has no human behind it at all — a
|
||||
// hired companion, an NPC ally. Permanent for the life of the fight; no command
|
||||
// clears it. Contrast seatIsAutopiloted, which is a human who stepped away.
|
||||
func (s *CombatSession) seatIsEngineDriven(seat int) bool {
|
||||
return s.actorStatusesForSeat(seat).EngineDriven
|
||||
}
|
||||
|
||||
// seatIsAutopiloted reports whether the engine, rather than a person, decides
|
||||
// this seat's action — because its human is away (a lapsed turn deadline) or
|
||||
// because it never had one.
|
||||
//
|
||||
// Every driver in the codebase reads this, which is exactly why the two reasons
|
||||
// share one accessor: the picker does not care WHY nobody is typing. What differs
|
||||
// is who is allowed to take the wheel back, and that question is asked in
|
||||
// beginCombatTurn against seatIsEngineDriven — never here.
|
||||
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
|
||||
return s.actorStatusesForSeat(seat).Autopilot
|
||||
st := s.actorStatusesForSeat(seat)
|
||||
return st.Autopilot || st.EngineDriven
|
||||
}
|
||||
|
||||
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
|
||||
// waiting on its player: it is down (forfeits silently) or latched onto
|
||||
// autopilot. driveCombatRound keeps stepping while this holds, so a round only
|
||||
// comes to rest on a live human's turn.
|
||||
// waiting on its player: it is down (forfeits silently), latched onto autopilot,
|
||||
// or has no human to wait for. driveCombatRound keeps stepping while this holds,
|
||||
// so a round only comes to rest on a live human's turn.
|
||||
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
||||
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
||||
}
|
||||
@@ -491,7 +524,7 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
||||
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
||||
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
||||
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
|
||||
owner := seats[0]
|
||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
||||
@@ -553,6 +586,12 @@ type CombatSeatSetup struct {
|
||||
// if they armed nothing. It is persisted onto the seat's statuses so every
|
||||
// later rebuild can re-apply the ability without re-spending it.
|
||||
ArmedAbility string
|
||||
// EngineDriven seats this combatant with no human behind it — the hired
|
||||
// companion today. It resolves from the opening round rather than after the
|
||||
// 3-minute away-player deadline (nobody is coming, so waiting out a deadline
|
||||
// would idle the fight and then announce him to the party as absent), and no
|
||||
// command can hand the wheel back to a player who does not exist.
|
||||
EngineDriven bool
|
||||
}
|
||||
|
||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||
|
||||
@@ -154,13 +154,36 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
|
||||
seats := sess.SeatUserIDs()
|
||||
players := make([]*Combatant, len(seats))
|
||||
levels := make([]int, len(seats))
|
||||
companions := make([]bool, len(seats))
|
||||
var enemy Combatant
|
||||
for seat, uid := range seats {
|
||||
// The ability this seat armed was consumed once, at fight start, and its
|
||||
// id parked on their statuses. Re-applying it here — not re-consuming —
|
||||
// is what makes a rage last the whole fight instead of one round.
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||
|
||||
// The hired companion has no sheet to load — by design, because a
|
||||
// player_meta row for him is the thing that would turn him into a real
|
||||
// character everywhere. Synthesize his seat instead. This branch is
|
||||
// load-bearing: buildZoneCombatants would fail on him, and one
|
||||
// unbuildable seat fails the whole rebuild, which is what every human's
|
||||
// !attack in the fight depends on.
|
||||
if isCompanionUser(uid) {
|
||||
class, level := companionLoadoutForRun(sess.RunID)
|
||||
player, en, _ := p.companionCombatant(class, level, monster, int(zone.Tier), run.DMMood)
|
||||
applySessionBuffs(&player, st)
|
||||
players[seat] = &player
|
||||
levels[seat], companions[seat] = level, true
|
||||
if seat == 0 {
|
||||
// Unreachable today (seat 0 is always the leader), but if it ever
|
||||
// isn't, the enemy still has to come from somewhere.
|
||||
enemy = en
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
player, e, dc, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||
}
|
||||
@@ -171,22 +194,47 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
// stat deltas are applied here — and only that seat's own.
|
||||
applySessionBuffs(&player, st)
|
||||
players[seat] = &player
|
||||
if dc != nil {
|
||||
levels[seat] = dc.Level
|
||||
}
|
||||
if seat == 0 {
|
||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||
// Only the *player* half of the build varies by seat.
|
||||
enemy = e
|
||||
// Party-only enemy HP bump, re-derived each turn from the template so
|
||||
// it never compounds. Matches the scalar startPartyCombatSession used
|
||||
// for the initial persist; solo (roster 1) scales by 1.0.
|
||||
if sess.IsParty() {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What each seat costs the enemy, priced against the leader. This runs after
|
||||
// the loop and not inside it, because a seat's weight is relative to seat 0's
|
||||
// level and the enemy's HP is scaled off the *summed* weight — neither is known
|
||||
// until every seat is built.
|
||||
applySeatWeights(players, levels, companions)
|
||||
|
||||
// Party-only enemy HP bump, re-derived each turn from the template so it never
|
||||
// compounds. Matches the scalar startPartyCombatSession used for the initial
|
||||
// persist; solo (one seat, weight 1) scales by 1.0.
|
||||
if sess.IsParty() {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeight(players))
|
||||
}
|
||||
return players, &enemy, nil
|
||||
}
|
||||
|
||||
// applySeatWeights prices every seat against the leader's level. Seat 0 is the
|
||||
// leader and always weighs a full 1.0.
|
||||
func applySeatWeights(players []*Combatant, levels []int, companions []bool) {
|
||||
if len(players) == 0 {
|
||||
return
|
||||
}
|
||||
leaderLevel := levels[0]
|
||||
for i, c := range players {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
c.SeatWeight = seatWeight(levels[i], leaderLevel, companions[i])
|
||||
}
|
||||
}
|
||||
|
||||
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
|
||||
// needs: the Berserker's rage flag, which decides whether the character owes a
|
||||
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
||||
|
||||
@@ -65,6 +65,19 @@ type turnActionEffect struct {
|
||||
EnemyDamage int
|
||||
PlayerHeal int
|
||||
EnemySkip bool // control spell: enemy forfeits its attack this round
|
||||
|
||||
// AllyHeal heals ANOTHER seat instead of the caster — the thing the engine
|
||||
// could not do until §1, and the reason a party cleric was a cleric in name
|
||||
// only. Every heal in the engine wrote to the acting seat: MistyHealProc,
|
||||
// HealItem, PlayerHeal above. Nothing could put a hit point on a friend, so
|
||||
// the class whose entire identity is keeping other people alive could not.
|
||||
// N3 shipped that way and no test noticed, because there was no party golden.
|
||||
//
|
||||
// Zero means no ally heal, which keeps every existing construction of this
|
||||
// struct meaning exactly what it meant before. AllySeat is only read when
|
||||
// AllyHeal > 0, so its zero value is never mistaken for "seat 0".
|
||||
AllyHeal int
|
||||
AllySeat int
|
||||
// ConcentrationDmg arms a per-round aura tick when a concentration damage
|
||||
// spell is cast: EnemyDamage is the burst that lands this round, this is
|
||||
// what re-ticks at every round_end after. Zero for one-shot spells; a
|
||||
@@ -560,6 +573,25 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||
}
|
||||
// §1 — heal somebody else. The caster's cursor stays where it is; only the
|
||||
// target's HP moves.
|
||||
//
|
||||
// A downed seat is NOT raised. Death in this engine is terminal for the fight
|
||||
// (the close-out marks them, the hospital takes them), and a heal that
|
||||
// resurrected a corpse would quietly rewrite the loss rules every close-out
|
||||
// path depends on. Healing keeps people up; it does not bring them back.
|
||||
if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) {
|
||||
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
|
||||
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
|
||||
before := tgt.playerHP
|
||||
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
|
||||
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
|
||||
Seat: eff.AllySeat, Desc: eff.Label,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Arm / replace the concentration aura. A new concentration cast overwrites
|
||||
// the old one (5e: one concentration at a time); non-concentration casts
|
||||
// leave any running aura alone.
|
||||
|
||||
@@ -332,8 +332,22 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// sqlExecer is the subset of *sql.DB / *sql.Tx that saveDnDCharacterExec needs,
|
||||
// so the upsert can run either standalone or inside a caller's transaction.
|
||||
type sqlExecer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// SaveDnDCharacter upserts the row.
|
||||
func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
return saveDnDCharacterExec(db.Get(), c)
|
||||
}
|
||||
|
||||
// saveDnDCharacterExec runs the dnd_character upsert against any executor. Used
|
||||
// directly by SaveDnDCharacter and inside grantDnDXP's overflow→renown
|
||||
// transaction (adventure_renown.go), so the character save and the renown
|
||||
// credit commit atomically.
|
||||
func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
|
||||
pending := 0
|
||||
if c.PendingSetup {
|
||||
pending = 1
|
||||
@@ -346,7 +360,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
if c.OnboardingSent {
|
||||
onboard = 1
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
_, err := ex.Exec(`
|
||||
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
|
||||
str_score, dex_score, con_score, int_score, wis_score, cha_score,
|
||||
hp_current, hp_max, temp_hp, armor_class,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -27,9 +29,11 @@ import (
|
||||
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
||||
// a "Phase 11" note — they require the boss turn-based engine.
|
||||
//
|
||||
// Cross-player targeting (--target @user) is deliberately deferred. SP2
|
||||
// supports self-target only. Heals on self work; ally buffs queue with the
|
||||
// caster as the target until SP3 wires up the multi-player resolution.
|
||||
// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human
|
||||
// on your expedition — see dnd_cast_target.go. Only heals may name somebody else
|
||||
// out of combat: UTILITY resolves on the caster, and everything else queues as a
|
||||
// PendingCast for the *caster's* next fight, where an ally target has nothing to
|
||||
// mean. In-combat targeting is splitCastTarget (combat_cmd.go).
|
||||
|
||||
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
|
||||
// Keep this minimal — the combat layer resolves the actual numbers on fire.
|
||||
@@ -97,6 +101,13 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
return p.dndCastDrop(ctx, c)
|
||||
}
|
||||
|
||||
// An ally target — `--target @alex`, or a trailing `@alex` — comes off the
|
||||
// string before the spell parser sees it, exactly as in combat. It is
|
||||
// resolved (and refused) further down, once we know the spell is a heal: a
|
||||
// target on anything else is a mistake, and refusing it early would cost us
|
||||
// the "which spell?" half of the error message.
|
||||
args, targetName := splitOutOfCombatTarget(args)
|
||||
|
||||
// Parse spell name + optional --upcast N.
|
||||
tokens := strings.Fields(args)
|
||||
upcast := 0
|
||||
@@ -111,11 +122,6 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
}
|
||||
i++
|
||||
}
|
||||
case "--target":
|
||||
// Reserved for SP3 — accept and ignore for now.
|
||||
if i+1 < len(tokens) {
|
||||
i++
|
||||
}
|
||||
default:
|
||||
spellTokens = append(spellTokens, tokens[i])
|
||||
}
|
||||
@@ -195,6 +201,23 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve an ally target now — BEFORE anything is spent. Out of combat only a
|
||||
// heal can land on somebody else: UTILITY resolves on the caster and
|
||||
// everything else queues as a PendingCast for *this* caster's next fight, so
|
||||
// a target on those would be accepted and then silently dropped.
|
||||
var targetUser id.UserID
|
||||
if targetName != "" {
|
||||
if spell.Effect != EffectSpellHeal {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"%s isn't a healing spell — outside a fight you can only cast those on someone else. Drop the target to cast it on yourself.", spell.Name))
|
||||
}
|
||||
uid, errMsg := resolveCastTargetOnExpedition(ctx.Sender, targetName)
|
||||
if errMsg != "" {
|
||||
return p.SendDM(ctx.Sender, errMsg)
|
||||
}
|
||||
targetUser = uid // empty when they named themselves — self-heal as usual
|
||||
}
|
||||
|
||||
// Material cost (Revivify, Raise Dead).
|
||||
if spell.MaterialCost > 0 {
|
||||
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||
@@ -220,6 +243,9 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
// debited above; if SaveDnDCharacter fails we refund.
|
||||
switch spell.Effect {
|
||||
case EffectSpellHeal:
|
||||
if targetUser != "" {
|
||||
return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel)
|
||||
}
|
||||
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
||||
case EffectUtility:
|
||||
return p.resolveUtility(ctx, c, spell, slotLevel)
|
||||
@@ -231,7 +257,10 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
|
||||
// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||
// rollOutOfCombatHeal is what the spell is worth in the caster's hands. It is a
|
||||
// property of the caster (their WIS, their domain), never of the body it lands
|
||||
// on, so an ally heal rolls exactly the same as a self-heal.
|
||||
func rollOutOfCombatHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
|
||||
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
||||
if dice == 0 {
|
||||
dice, faces = 1, 8 // safety fallback
|
||||
@@ -253,6 +282,11 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
||||
}
|
||||
heal += abilityModifier(c.WIS)
|
||||
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
||||
return heal
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||||
|
||||
before := c.HPCurrent
|
||||
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
||||
@@ -272,6 +306,51 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
||||
renderSlotsBrief(ctx.Sender)))
|
||||
}
|
||||
|
||||
// resolveAllyHealOutOfCombat puts the heal on somebody else's sheet. The slot is
|
||||
// already spent, so every failure below refunds it: a heal that lands on nobody
|
||||
// must not cost the caster anything.
|
||||
func (p *AdventurePlugin) resolveAllyHealOutOfCombat(ctx MessageContext, c *DnDCharacter, target id.UserID, spell SpellDefinition, slotLevel int) error {
|
||||
refund := func(msg string) error {
|
||||
if spell.Level > 0 {
|
||||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
|
||||
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||||
before, after, maxHP, err := healPartyMember(target, heal)
|
||||
|
||||
targetName := charName(target)
|
||||
if targetName == "" {
|
||||
targetName = target.Localpart()
|
||||
}
|
||||
casterName := charName(ctx.Sender)
|
||||
if casterName == "" {
|
||||
casterName = ctx.Sender.Localpart()
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, errHealTargetFull):
|
||||
return refund(fmt.Sprintf("**%s** is already at full health (%d/%d). Slot kept.", targetName, before, maxHP))
|
||||
case errors.Is(err, errHealTargetDown):
|
||||
// The same rule the combat path holds: a heal is not a resurrection.
|
||||
return refund(fmt.Sprintf("**%s** is down — a heal won't bring them back. Slot kept.", targetName))
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return refund(fmt.Sprintf("**%s** hasn't run `!setup` yet, so there's no sheet to heal.", targetName))
|
||||
case err != nil:
|
||||
return refund("Couldn't apply the heal: " + err.Error())
|
||||
}
|
||||
|
||||
_ = p.SendDM(target, fmt.Sprintf(
|
||||
"🩹 **%s** casts **%s** on you — %d HP back (%d → %d / %d).",
|
||||
casterName, spell.Name, after-before, before, after, maxHP))
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"🩹 **%s** on **%s** — restored %d HP (%d → %d / %d). %s",
|
||||
spell.Name, targetName, after-before, before, after, maxHP,
|
||||
renderSlotsBrief(ctx.Sender)))
|
||||
}
|
||||
|
||||
// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||
@@ -676,6 +755,7 @@ func renderCastHelp(c *DnDCharacter) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("**Cast a spell**\n\n")
|
||||
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
|
||||
b.WriteString(" `!cast <heal> @friend` to heal someone on your expedition.\n")
|
||||
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
|
||||
b.WriteString("Run `!spells` for your full list.\n\n")
|
||||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||||
|
||||
139
internal/plugin/dnd_cast_ally_e2e_test.go
Normal file
139
internal/plugin/dnd_cast_ally_e2e_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The whole `!cast cure wounds @friend` path, out of combat, through the real
|
||||
// handler: parse → class/known/prepared gates → slot debit → the ally's sheet.
|
||||
//
|
||||
// The seams are unit-tested next door; this exists for the one thing they cannot
|
||||
// see — that the slot ledger and the HP write agree about whether the heal
|
||||
// happened. A refund that does not fire is a slot a player paid for nothing, and
|
||||
// a refund that fires twice is a free heal.
|
||||
|
||||
// castingCleric turns an existing sheet into a cleric who knows and has prepared
|
||||
// Cure Wounds, with slots to spend.
|
||||
func castingCleric(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load %s: %v", uid, err)
|
||||
}
|
||||
c.Class = ClassCleric
|
||||
c.Level = 5
|
||||
c.WIS = 16
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setSpellSlotsForCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addKnownSpell(uid, "cure_wounds", "class", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setSpellPrepared(uid, "cure_wounds", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func slotsUsed(t *testing.T, uid id.UserID, level int) int {
|
||||
t.Helper()
|
||||
slots, err := getSpellSlots(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return slots[level][1]
|
||||
}
|
||||
|
||||
func setCharHP(t *testing.T, uid id.UserID, hp int) {
|
||||
t.Helper()
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load %s: %v", uid, err)
|
||||
}
|
||||
c.HPCurrent = hp
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCastAlly_OutOfCombat_HealsTheFriendAndSpendsOneSlot(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader, member := seatedMember(t, "e2eheal")
|
||||
castingCleric(t, leader)
|
||||
setCharHP(t, member, 5) // of 20
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
|
||||
t.Fatalf("cast: %v", err)
|
||||
}
|
||||
|
||||
mc, _ := LoadDnDCharacter(member)
|
||||
if mc.HPCurrent <= 5 {
|
||||
t.Fatalf("the friend is still on %d HP; the heal landed on nobody", mc.HPCurrent)
|
||||
}
|
||||
// The caster must NOT have healed themselves — the bug this whole section exists for.
|
||||
if lc, _ := LoadDnDCharacter(leader); lc.HPCurrent != lc.HPMax {
|
||||
t.Fatalf("caster HP moved to %d/%d; the heal landed on the caster", lc.HPCurrent, lc.HPMax)
|
||||
}
|
||||
if used := slotsUsed(t, leader, 1); used != 1 {
|
||||
t.Fatalf("level-1 slots used = %d, want exactly 1", used)
|
||||
}
|
||||
}
|
||||
|
||||
// Naming a full-HP ally must cost nothing: the slot is debited before the target
|
||||
// is touched, so this is the refund path firing for real.
|
||||
func TestCastAlly_OutOfCombat_FullHPAllyRefundsTheSlot(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader, member := seatedMember(t, "e2efull")
|
||||
castingCleric(t, leader)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
|
||||
t.Fatalf("cast: %v", err)
|
||||
}
|
||||
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||
t.Fatalf("level-1 slots used = %d after healing a full-HP ally; the slot was not refunded", used)
|
||||
}
|
||||
}
|
||||
|
||||
// A target nobody can reach is refused before anything is spent.
|
||||
func TestCastAlly_OutOfCombat_StrangerCostsNothing(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader, _ := seatedMember(t, "e2estranger")
|
||||
castingCleric(t, leader)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @nobody"); err != nil {
|
||||
t.Fatalf("cast: %v", err)
|
||||
}
|
||||
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||
t.Fatalf("level-1 slots used = %d after naming a stranger; nothing should have been spent", used)
|
||||
}
|
||||
}
|
||||
|
||||
// A target on a spell that cannot use one is refused, rather than quietly
|
||||
// dropping the target and firing the spell at the caster — which is what the old
|
||||
// "accept and ignore" parse did.
|
||||
func TestCastAlly_OutOfCombat_TargetOnANonHealIsRefused(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader, member := seatedMember(t, "e2enonheal")
|
||||
castingCleric(t, leader)
|
||||
if err := addKnownSpell(leader, "guiding_bolt", "class", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "guiding bolt @"+member.Localpart()); err != nil {
|
||||
t.Fatalf("cast: %v", err)
|
||||
}
|
||||
if used := slotsUsed(t, leader, 1); used != 0 {
|
||||
t.Fatalf("level-1 slots used = %d; a targeted non-heal must be refused before it is paid for", used)
|
||||
}
|
||||
if lc, _ := LoadDnDCharacter(leader); lc.PendingCast != "" {
|
||||
t.Fatalf("guiding bolt queued as %q; the target should have refused the cast outright", lc.PendingCast)
|
||||
}
|
||||
}
|
||||
156
internal/plugin/dnd_cast_target.go
Normal file
156
internal/plugin/dnd_cast_target.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Out-of-combat ally targeting for `!cast` — the other half of §1.
|
||||
//
|
||||
// splitCastTarget (combat_cmd.go) resolves a target against the people *in the
|
||||
// fight*. Out of combat there is no fight, so the equivalent set is the people
|
||||
// on your expedition: the party is who you are standing next to. Healing a
|
||||
// stranger across the world is not a thing a cleric at camp can do, and scoping
|
||||
// it to the party keeps the two `!cast` paths answering the same question.
|
||||
//
|
||||
// Only heals may name somebody else. Everything else `!cast` can do out of
|
||||
// combat either resolves on the caster (UTILITY) or queues as a PendingCast for
|
||||
// **the caster's** next fight — an ally target on those would be silently
|
||||
// dropped, which is how `--target` came to be swallowed in the first place.
|
||||
|
||||
// splitOutOfCombatTarget peels an ally target off a `!cast` argument, using the
|
||||
// same two spellings the combat path accepts: the `--target @alex` flag, and a
|
||||
// plain trailing `@alex`. The bare form requires the `@` here (unlike in combat,
|
||||
// where the roster disambiguates) so a trailing spell word is never mistaken for
|
||||
// a name.
|
||||
//
|
||||
// Returns (remainingArgs, name). name is empty when no target was named.
|
||||
func splitOutOfCombatTarget(args string) (string, string) {
|
||||
fields := strings.Fields(args)
|
||||
for i := 0; i < len(fields); i++ {
|
||||
if !strings.EqualFold(fields[i], "--target") {
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(fields) {
|
||||
return args, ""
|
||||
}
|
||||
name := strings.TrimPrefix(fields[i+1], "@")
|
||||
fields = append(fields[:i], fields[i+2:]...)
|
||||
return strings.Join(fields, " "), name
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return args, ""
|
||||
}
|
||||
if last := fields[len(fields)-1]; strings.HasPrefix(last, "@") {
|
||||
if name := strings.TrimPrefix(last, "@"); name != "" {
|
||||
return strings.Join(fields[:len(fields)-1], " "), name
|
||||
}
|
||||
}
|
||||
return args, ""
|
||||
}
|
||||
|
||||
// resolveCastTargetOnExpedition maps a named target to a human on the caster's
|
||||
// active expedition. errMsg is non-empty and player-facing on any failure; a
|
||||
// caster who names somebody must never fall through to healing themselves,
|
||||
// because that quietly burns the slot on the wrong body.
|
||||
func resolveCastTargetOnExpedition(caster id.UserID, name string) (id.UserID, string) {
|
||||
exp, _, err := activeExpeditionFor(caster)
|
||||
if err != nil {
|
||||
return "", "Couldn't look up your expedition."
|
||||
}
|
||||
if exp == nil {
|
||||
return "", "You're not travelling with anyone. `!cast` on yourself, or join a party first."
|
||||
}
|
||||
seats, err := partyHumans(exp.ID, exp.UserID)
|
||||
if err != nil {
|
||||
return "", "Couldn't look up your party."
|
||||
}
|
||||
for _, s := range seats {
|
||||
uid := string(s.UserID)
|
||||
if strings.EqualFold(uid, name) || strings.EqualFold(id.UserID(uid).Localpart(), name) {
|
||||
if s.UserID == caster {
|
||||
// Naming yourself is just casting it on yourself.
|
||||
return "", ""
|
||||
}
|
||||
return s.UserID, ""
|
||||
}
|
||||
// Character name, which is what players actually call each other.
|
||||
if n := charName(s.UserID); n != "" && strings.EqualFold(n, name) {
|
||||
if s.UserID == caster {
|
||||
return "", ""
|
||||
}
|
||||
return s.UserID, ""
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(name, companionDisplayName) {
|
||||
return "", companionDisplayName + " patches himself up at camp. Save the slot."
|
||||
}
|
||||
return "", fmt.Sprintf("**%s** isn't on your expedition. You can only heal someone you're travelling with.", name)
|
||||
}
|
||||
|
||||
// errHealTargetFull / errHealTargetDown are the two ways an ally heal declines
|
||||
// to spend the slot. Both are recoverable: the caller refunds.
|
||||
var (
|
||||
errHealTargetFull = errors.New("target already at full HP")
|
||||
errHealTargetDown = errors.New("target is down")
|
||||
)
|
||||
|
||||
// healPartyMember applies a heal to somebody else's sheet.
|
||||
//
|
||||
// It does NOT take the target's advUserLock. Gifting sets the precedent
|
||||
// (adventure_gifting.go): mutate the other player's row with one guarded
|
||||
// statement rather than a read-modify-write under two locks. Two clerics healing
|
||||
// each other at the same instant would otherwise take those locks in opposite
|
||||
// orders and deadlock the pair of them.
|
||||
//
|
||||
// The clamp lives in SQL for the same reason — read, add, cap and write are one
|
||||
// statement, so a heal landing between a concurrent heal's read and write cannot
|
||||
// push anybody past their maximum.
|
||||
func healPartyMember(target id.UserID, amount int) (before, after, maxHP int, err error) {
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
err = tx.QueryRow(
|
||||
`SELECT hp_current, hp_max FROM dnd_character WHERE user_id = ?`,
|
||||
string(target)).Scan(&before, &maxHP)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, 0, sql.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
switch {
|
||||
case before <= 0:
|
||||
// A heal is not a resurrection — the same rule the combat path holds.
|
||||
return before, before, maxHP, errHealTargetDown
|
||||
case before >= maxHP:
|
||||
return before, before, maxHP, errHealTargetFull
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(
|
||||
`UPDATE dnd_character
|
||||
SET hp_current = MIN(hp_max, hp_current + ?),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ? AND hp_current > 0`,
|
||||
amount, string(target)); err != nil {
|
||||
return before, before, maxHP, err
|
||||
}
|
||||
if err = tx.QueryRow(
|
||||
`SELECT hp_current FROM dnd_character WHERE user_id = ?`,
|
||||
string(target)).Scan(&after); err != nil {
|
||||
return before, before, maxHP, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return before, before, maxHP, err
|
||||
}
|
||||
return before, after, maxHP, nil
|
||||
}
|
||||
102
internal/plugin/dnd_cast_target_test.go
Normal file
102
internal/plugin/dnd_cast_target_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The out-of-combat half of §1. `--target` was parsed and thrown away here since
|
||||
// SP2 ("reserved for SP3, accept and ignore"), so a party cleric standing over a
|
||||
// bleeding friend between fights could do precisely nothing for them.
|
||||
|
||||
func TestSplitOutOfCombatTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, args, wantRest, wantTarget string
|
||||
}{
|
||||
{"flag", "cure wounds --target @alex", "cure wounds", "alex"},
|
||||
{"flag mid-string", "cure wounds --target @alex --upcast 3", "cure wounds --upcast 3", "alex"},
|
||||
{"flag without the @", "cure wounds --target alex", "cure wounds", "alex"},
|
||||
{"trailing mention", "cure wounds @alex", "cure wounds", "alex"},
|
||||
{"no target", "cure wounds", "cure wounds", ""},
|
||||
// A trailing bare word is a spell word, not a name — the `@` is what makes
|
||||
// it a target out of combat. Getting this wrong eats half of "cure wounds".
|
||||
{"bare trailing word is not a target", "healing word", "healing word", ""},
|
||||
{"upcast digit is not a target", "cure wounds --upcast 3", "cure wounds --upcast 3", ""},
|
||||
{"dangling flag", "cure wounds --target", "cure wounds --target", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rest, target := splitOutOfCombatTarget(tc.args)
|
||||
if rest != tc.wantRest || target != tc.wantTarget {
|
||||
t.Fatalf("splitOutOfCombatTarget(%q) = (%q, %q), want (%q, %q)",
|
||||
tc.args, rest, target, tc.wantRest, tc.wantTarget)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The target set is the expedition, not the world: you can heal the person you
|
||||
// are travelling with, and nobody else.
|
||||
func TestResolveCastTarget_OnlyThePartyIsReachable(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader, member := seatedMember(t, "casttarget")
|
||||
|
||||
uid, errMsg := resolveCastTargetOnExpedition(leader, member.Localpart())
|
||||
if errMsg != "" || uid != member {
|
||||
t.Fatalf("leader → member = (%q, %q); want the member, no error", uid, errMsg)
|
||||
}
|
||||
|
||||
// Somebody with a sheet, but not on this expedition.
|
||||
stranger := id.UserID("@stranger-casttarget:example.org")
|
||||
zoneCmdTestCharacter(t, stranger, 1)
|
||||
if _, errMsg := resolveCastTargetOnExpedition(leader, stranger.Localpart()); errMsg == "" {
|
||||
t.Fatal("healed a stranger across the world; only the party is reachable")
|
||||
}
|
||||
|
||||
// Naming yourself is just casting it on yourself: no target, no error. A
|
||||
// refusal here would make `!cast cure wounds @me` cost a slot for nothing.
|
||||
if uid, errMsg := resolveCastTargetOnExpedition(leader, leader.Localpart()); uid != "" || errMsg != "" {
|
||||
t.Fatalf("self-target = (%q, %q); want the self-heal path, silently", uid, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealPartyMember_ClampsAtMaxAndWillNotRaiseTheDead(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
target := id.UserID("@heal-target:example.org")
|
||||
zoneCmdTestCharacter(t, target, 1) // HPMax 20
|
||||
|
||||
setHP := func(hp int) {
|
||||
c, err := LoadDnDCharacter(target)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
c.HPCurrent = hp
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A heal bigger than the wound tops out at max rather than overflowing.
|
||||
setHP(15)
|
||||
before, after, maxHP, err := healPartyMember(target, 100)
|
||||
if err != nil || before != 15 || after != 20 || maxHP != 20 {
|
||||
t.Fatalf("overheal = (%d → %d / %d, %v); want clamped to 20", before, after, maxHP, err)
|
||||
}
|
||||
|
||||
// Already full: decline so the caller can refund. Spending a slot to heal
|
||||
// zero HP is the kind of thing players never forgive.
|
||||
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetFull) {
|
||||
t.Fatalf("healing a full-HP ally = %v; want errHealTargetFull", err)
|
||||
}
|
||||
|
||||
// Down: a heal is not a resurrection. Same rule the combat path holds.
|
||||
setHP(0)
|
||||
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetDown) {
|
||||
t.Fatalf("healing a downed ally = %v; want errHealTargetDown", err)
|
||||
}
|
||||
if c, _ := LoadDnDCharacter(target); c == nil || c.HPCurrent != 0 {
|
||||
t.Fatal("the downed ally was raised by a heal")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -305,6 +307,13 @@ func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*D
|
||||
c := autoBuildCharacter(userID, char)
|
||||
c.AutoMigrated = true
|
||||
c.PendingSetup = false
|
||||
// Seed the canonical player_meta row before handing back a confirmed
|
||||
// character. Auto-migration otherwise mints a player_meta-less character
|
||||
// (the camcast straggler) that fails every legacy-layer command with
|
||||
// "sql: no rows". No-op for legacy players who already have the row.
|
||||
if err := ensurePlayerMetaSeed(userID); err != nil {
|
||||
return nil, false, fmt.Errorf("seed player_meta: %w", err)
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -567,4 +576,85 @@ func markAdventureDead(userID id.UserID, source, location string) {
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
|
||||
}
|
||||
emitDeathNews(userID, location)
|
||||
}
|
||||
|
||||
// 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.
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
lvl := charLevel(userID)
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
|
||||
EventType: "death",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Zone: location,
|
||||
Level: lvl,
|
||||
Outcome: "lost",
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
// emitRetreatNews files a BULLETIN when an expedition ends with the player
|
||||
// walking out alive.
|
||||
//
|
||||
// Until this existed the news had no way to say "it went badly but nobody
|
||||
// died", so it never said it. Pete's whole taxonomy was arrival, companion_hire,
|
||||
// death, milestone, rival_result and zone_first — every one of them a win, a
|
||||
// death, or an introduction. A run that simply fell apart emitted nothing, and
|
||||
// the feed showed a realm where adventurers only ever triumph or die.
|
||||
//
|
||||
// That was not a rare gap. Before §6, a cleric retreated on 167 of 500
|
||||
// simulated expeditions: a third of that class's runs ended in a way the news
|
||||
// was structurally incapable of reporting. Casters did not look unlucky in the
|
||||
// feed — they looked absent.
|
||||
//
|
||||
// Bulletin, not priority: a retreat is a bad day, not a funeral. A death already
|
||||
// files its own priority dispatch from markAdventureDead, and a wipe that killed
|
||||
// someone must not ALSO be reported as a retreat — hence the reason gate rather
|
||||
// than an "expedition ended" catch-all. An idle reap is excluded too: a player
|
||||
// who closed their laptop did not flee anything, and Pete announcing that they
|
||||
// were driven from the field would be a lie about a person by name.
|
||||
func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) {
|
||||
switch reason {
|
||||
case lossCombatRetreat, lossCombatFlee:
|
||||
default:
|
||||
return // a death tells its own story; an idle reap is not a story
|
||||
}
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
zone := zoneOrFallback(zoneID)
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("retreat:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
|
||||
EventType: "retreat",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Level: charLevel(userID),
|
||||
Count: day, // the day they got to before it fell apart
|
||||
Outcome: "retreated",
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
@@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
startMood = 55
|
||||
}
|
||||
if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
|
||||
startMood += b
|
||||
}
|
||||
exp := &Expedition{
|
||||
ID: newExpeditionID(),
|
||||
UserID: string(userID),
|
||||
|
||||
@@ -325,6 +325,13 @@ func applyCampRest(e *Expedition, kind string) string {
|
||||
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
|
||||
_ = refreshAllResources(uid)
|
||||
_ = refreshSpellSlots(uid)
|
||||
// The companion sleeps at the same fire. His slots and his wounds are not
|
||||
// dnd_spell_slots / dnd_character rows — he has none — so nothing above
|
||||
// reaches him, and without these two his pool and his body would only ever
|
||||
// go down. Camp is where a caster gets their slots back and a body gets
|
||||
// patched up, and he is at the camp.
|
||||
_ = refreshCompanionSlots(e.ID)
|
||||
_ = refreshCompanionHP(e.ID)
|
||||
_ = ReplenishHarvestNodes(e)
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,10 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
return p.expeditionCmdParty(ctx)
|
||||
case "leave":
|
||||
return p.expeditionCmdLeave(ctx)
|
||||
case "hire":
|
||||
return p.expeditionCmdHire(ctx, rest)
|
||||
case "dismiss":
|
||||
return p.expeditionCmdDismiss(ctx)
|
||||
case "extract":
|
||||
return p.handleExtractCmd(ctx, "")
|
||||
case "resume":
|
||||
@@ -122,7 +126,9 @@ func expeditionHelpText() string {
|
||||
b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
|
||||
b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
|
||||
b.WriteString("`!expedition party` — who's with you\n")
|
||||
b.WriteString("`!expedition leave` — turn back for town (members only)\n\n")
|
||||
b.WriteString("`!expedition leave` — turn back for town (members only)\n")
|
||||
b.WriteString("`!expedition hire [class]` — pay Pete to fill the role you're missing\n")
|
||||
b.WriteString("`!expedition dismiss` — send Pete home\n\n")
|
||||
b.WriteString("**Mid-expedition:**\n")
|
||||
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
|
||||
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
|
||||
@@ -283,6 +289,12 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
formatRespecDuration(remaining)))
|
||||
}
|
||||
zoneTok, packTok := splitFirstWord(rest)
|
||||
// N5/D1c: the campaign finale is reached here but is not a normal zone — it
|
||||
// runs a single auto-resolved boss fight with its own unlock gate, no
|
||||
// supplies, no walk. Intercept before the zone/loadout machinery.
|
||||
if strings.EqualFold(zoneTok, "epilogue") {
|
||||
return p.handleEpilogueEncounter(ctx)
|
||||
}
|
||||
available := zonesForLevel(c.Level)
|
||||
zoneID, ok := resolveZoneInput(zoneTok, available)
|
||||
if !ok {
|
||||
@@ -371,41 +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++
|
||||
}
|
||||
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
|
||||
@@ -427,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
|
||||
|
||||
@@ -400,6 +400,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
|
||||
line := pickMorningBriefing(e.CurrentDay)
|
||||
body := renderMorningBriefing(e, line, burn)
|
||||
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||
body += "\n" + sl + "\n"
|
||||
}
|
||||
if restSummary != "" {
|
||||
body += "\n💤 _" + restSummary + "_\n"
|
||||
}
|
||||
@@ -513,6 +516,9 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
|
||||
|
||||
line := pickMorningBriefing(e.CurrentDay)
|
||||
body := renderMorningBriefing(e, line, burn)
|
||||
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||
body += "\n" + sl + "\n"
|
||||
}
|
||||
if forced {
|
||||
body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n"
|
||||
}
|
||||
|
||||
@@ -95,21 +95,40 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
|
||||
return e, tax, nil
|
||||
}
|
||||
|
||||
// The reasons a run can end badly. They were bare strings at four call sites;
|
||||
// they are constants now because the news seam has to tell them apart — a
|
||||
// retreat is a story and a death is a different story, and an idle reap is
|
||||
// neither.
|
||||
const (
|
||||
lossCombatDeath = "combat death"
|
||||
lossCombatRetreat = "combat retreat" // solo: ran out the phase clock and withdrew
|
||||
lossCombatFlee = "combat flee" // party: the turn engine broke off
|
||||
lossIdleTimeout = "run idle-timeout (§4.3 stale-run reap)"
|
||||
)
|
||||
|
||||
// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based
|
||||
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
||||
// death) into the forced-extract flow. Those sites already abandon the
|
||||
// zone run, but without flipping the wrapping expedition the ambient
|
||||
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
||||
// when there is no active expedition for this user.
|
||||
//
|
||||
// It is also the one chokepoint every bad ending passes through, which makes it
|
||||
// where the retreat dispatch is filed. Read the expedition BEFORE the extract:
|
||||
// forcedExtractExpedition stamps it 'abandoned' and zeroes the live fields, so
|
||||
// afterwards there is no day count left to report.
|
||||
func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
|
||||
exp, err := getActiveExpedition(userID)
|
||||
if err != nil || exp == nil {
|
||||
return
|
||||
}
|
||||
day, zoneID := exp.CurrentDay, exp.ZoneID
|
||||
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
||||
slog.Warn("expedition: force-extract on run loss",
|
||||
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
||||
return
|
||||
}
|
||||
emitRetreatNews(userID, reason, zoneID, day)
|
||||
}
|
||||
|
||||
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
||||
@@ -170,7 +189,17 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
|
||||
exp.Status = ExpeditionStatusComplete
|
||||
_ = retireAllRegionRuns(exp)
|
||||
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
|
||||
return p.AwardCompletionMilestones(exp, false)
|
||||
lines := p.AwardCompletionMilestones(exp, false)
|
||||
// N6/D3: the Shadow's payoff for this zone — a crow (player was first) or a
|
||||
// waiting journal page (the Shadow cleared it first). Appended after the
|
||||
// milestone lines so the campaign beat reads last.
|
||||
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
|
||||
lines = append(lines, sl)
|
||||
}
|
||||
// News: boss down means the zone is cleared. Realm-first → PRIORITY,
|
||||
// repeat → BULLETIN. No-op unless the Pete seam is enabled.
|
||||
emitZoneClearNews(userID, exp)
|
||||
return lines
|
||||
}
|
||||
|
||||
// midZoneRegionClear reports whether the just-completed run (runID) is the
|
||||
|
||||
@@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
qty++
|
||||
}
|
||||
if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen
|
||||
qty += b
|
||||
}
|
||||
tier := zoneTierFromID(res.ZoneID)
|
||||
for i := 0; i < qty; i++ {
|
||||
item := AdvItem{
|
||||
|
||||
@@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
||||
return 0, "", nil
|
||||
}
|
||||
delta, reason := dailyThreatDrift(e.DMMood)
|
||||
// N7/B3 the Omen — a "still waters" week subtracts from the daily threat
|
||||
// *rise* only (guarded on delta > 0), floored so the worst case is threat
|
||||
// holding steady for the day; it never turns a rise into active decay.
|
||||
if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 {
|
||||
if delta -= r; delta < 0 {
|
||||
delta = 0
|
||||
}
|
||||
}
|
||||
if delta == 0 {
|
||||
return 0, reason, nil
|
||||
}
|
||||
|
||||
@@ -161,6 +161,16 @@ func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) error {
|
||||
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
|
||||
if c.Level >= dndMaxLevel {
|
||||
b.WriteString("XP: capped at L20.")
|
||||
if rl := advChar.RenownLevel(); rl > 0 {
|
||||
into, cost := renownXPIntoLevel(advChar.RenownXP)
|
||||
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
|
||||
if rank := renownRankFor(rl); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" — _%s_", rank))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
|
||||
} else {
|
||||
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
|
||||
}
|
||||
} else {
|
||||
next := dndXPToNextLevel(c.Level)
|
||||
pct := int(100.0 * float64(c.XP) / float64(next))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -290,6 +291,22 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
|
||||
// Idempotent — !setup confirm after a respec wipe will repopulate.
|
||||
_ = ensureSpellsForCharacter(c)
|
||||
|
||||
// A new character walked through the gates — file a welcome dispatch to
|
||||
// Pete. GUID keyed on the user alone so a later respec/re-confirm can't
|
||||
// re-announce the same arrival. No-op unless the seam is enabled.
|
||||
if name := charName(ctx.Sender); name != "" {
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: "arrival:" + eventToken(ctx.Sender, "arrival"),
|
||||
EventType: "arrival",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
ClassRace: classRaceLabel(c),
|
||||
Level: c.Level,
|
||||
OccurredAt: ts,
|
||||
}, ctx.Sender, "")
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, renderSetupComplete(c))
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,20 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
||||
if adv != nil && adv.DisplayName != "" {
|
||||
name = adv.DisplayName
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
|
||||
renownLevel := 0
|
||||
if adv != nil {
|
||||
renownLevel = adv.RenownLevel()
|
||||
}
|
||||
nameLine := name
|
||||
if m := renownMarker(renownLevel); m != "" {
|
||||
nameLine = fmt.Sprintf("%s %s", name, m)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", nameLine, c.Level, ri.Display, ci.Display))
|
||||
if renownLevel > 0 {
|
||||
if rank := renownRankFor(renownLevel); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" _Renown %d — %s_\n", renownLevel, rank))
|
||||
}
|
||||
}
|
||||
if c.Subclass != "" {
|
||||
if si, ok := subclassInfo(c.Subclass); ok {
|
||||
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
|
||||
|
||||
@@ -127,15 +127,26 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
|
||||
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
|
||||
}
|
||||
|
||||
// Cap at L20 — overflow XP is silently dropped.
|
||||
if c.Level >= dndMaxLevel {
|
||||
// N7/B2 Renown — persist the character; at the cap, overflow XP that used to
|
||||
// be dropped converts to Renown in the SAME transaction as the save, so a
|
||||
// crash can neither lose the overflow nor double-credit it on the next grant.
|
||||
// Renown is title + cosmetic + capped loot/XP perks, never combat stats, so
|
||||
// the balance corpus stays valid.
|
||||
var renownFrom, renownTo int
|
||||
if c.Level >= dndMaxLevel && c.XP > 0 {
|
||||
overflow := c.XP
|
||||
c.XP = 0
|
||||
}
|
||||
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
from, to, err := saveDnDCharacterWithOverflow(c, overflow)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
renownFrom, renownTo = renownLevelFor(from), renownLevelFor(to)
|
||||
} else if err := SaveDnDCharacter(c); err != nil {
|
||||
return events, err
|
||||
}
|
||||
|
||||
p.announceRenown(userID, renownFrom, renownTo)
|
||||
|
||||
if len(events) > 0 {
|
||||
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
|
||||
// superiority cap from 4 → 5; reconcile any level-gated subclass pool
|
||||
|
||||
@@ -988,6 +988,25 @@ func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []strin
|
||||
// inter-phase delays — see resolveCombatRoom for the contract. For
|
||||
// non-combat rooms (entry, trap), phases is nil and outcome carries the
|
||||
// resolution narration.
|
||||
// currentSecretNode returns the run's current graph node when it is a secret
|
||||
// room, so resolveRoom can divert it to the treasure-cache resolver. Returns
|
||||
// false for a non-secret node, an unknown zone, or a legacy row that never got
|
||||
// a CurrentNode written (those pre-date branching graphs and have no secrets).
|
||||
func currentSecretNode(run *DungeonRun) (ZoneNode, bool) {
|
||||
if run.CurrentNode == "" {
|
||||
return ZoneNode{}, false
|
||||
}
|
||||
g, ok := loadZoneGraph(run.ZoneID)
|
||||
if !ok {
|
||||
return ZoneNode{}, false
|
||||
}
|
||||
n, ok := g.Nodes[run.CurrentNode]
|
||||
if !ok || n.Kind != NodeKindSecret {
|
||||
return ZoneNode{}, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
// Revisit R2 — a cleared room stays cleared. Advancing out of a room the
|
||||
// player backtracked into must not re-roll its combat or re-arm its trap.
|
||||
@@ -1004,6 +1023,14 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
||||
if run.RoomIsCleared(run.CurrentRoom) {
|
||||
return
|
||||
}
|
||||
// N5/D4 — a secret room is a no-combat treasure cache, not the exploration
|
||||
// fight its RoomType collapses to. Keyed off the graph node (which still
|
||||
// carries NodeKindSecret) rather than CurrentRoomType (which does not), so
|
||||
// it must be checked before the RoomType switch below.
|
||||
if node, ok := currentSecretNode(run); ok {
|
||||
outcome = p.resolveSecretRoom(userID, run, zone, node)
|
||||
return
|
||||
}
|
||||
switch run.CurrentRoomType() {
|
||||
case RoomEntry:
|
||||
return
|
||||
@@ -1103,6 +1130,13 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(line)
|
||||
}
|
||||
// D1b campaign capstone. The compact autopilot resolves boss rooms
|
||||
// itself (the primary long-expedition path), so the epilogue has to
|
||||
// fire here too — otherwise almost every player clears the boss
|
||||
// without ever seeing it.
|
||||
if isBoss {
|
||||
writeBossEpilogue(&ob, zone.ID)
|
||||
}
|
||||
outcome = ob.String()
|
||||
return
|
||||
}
|
||||
@@ -1173,9 +1207,9 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
// seat off HP, which for a solo walker is the same `!TimedOut` rule.
|
||||
closeOutZoneLoss(pres, seated, zone, "zone")
|
||||
if !result.TimedOut {
|
||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
||||
forceExtractExpeditionForRunLoss(userID, lossCombatDeath)
|
||||
} else {
|
||||
forceExtractExpeditionForRunLoss(userID, "combat retreat")
|
||||
forceExtractExpeditionForRunLoss(userID, lossCombatRetreat)
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
|
||||
ob.WriteString(line)
|
||||
|
||||
@@ -202,6 +202,52 @@ func scanMoodEventsFromEvents(runID string, events []CombatEvent) (nat20s, nat1s
|
||||
//
|
||||
// Higher-tier zones currently fall through to the pre-D2a flat-percent
|
||||
// nick — D3a/D4a will add Tier 2+ trap catalogs.
|
||||
// resolveSecretRoom resolves a NodeKindSecret room as a no-combat treasure
|
||||
// cache (N5/D4). Finding the secret — via the perception/stat check on the fork
|
||||
// that hangs it, or a cross-zone key — is the reward: a guaranteed journal page
|
||||
// (the Hollow King's fragments gather in hidden rooms), a LootBias-weighted
|
||||
// treasure roll, a guaranteed zone-tier consumable cache, and, for the two
|
||||
// key-bearing secrets, a cross-zone key. No enemy, no threat.
|
||||
//
|
||||
// LootBias has been authored on every secret node since the branching-zones
|
||||
// phase and was dead code until now; it finally scales the treasure weight here.
|
||||
func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, node ZoneNode) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(secretRoomDiscoveryLine(node))
|
||||
|
||||
var rewards []string
|
||||
if line := p.grantJournalPage(userID, nil); line != "" {
|
||||
rewards = append(rewards, line)
|
||||
}
|
||||
if line := p.grantSecretRoomKey(userID, node); line != "" {
|
||||
rewards = append(rewards, line)
|
||||
}
|
||||
|
||||
// LootBias-weighted treasure roll. Floors at the elite weight so even the
|
||||
// leanest secret out-rolls a standard kill; the richest (abyss 3.0) still
|
||||
// rolls below a boss. checkTreasureDrop DMs the discovery itself on a hit,
|
||||
// exactly as a combat-room drop does, so it isn't folded into the outcome.
|
||||
weight := node.Content.LootBias
|
||||
if weight < advTreasureWeightElite {
|
||||
weight = advTreasureWeightElite
|
||||
}
|
||||
p.rollZoneTreasure(userID, zone.ID, weight)
|
||||
|
||||
// Guaranteed cache so the room always pays out something visible.
|
||||
for _, item := range consumableCache(int(zone.Tier), secretRoomCacheCount) {
|
||||
it := item
|
||||
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
|
||||
rewards = append(rewards, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(rewards) > 0 {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(strings.Join(rewards, "\n"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
|
||||
dndChar, _ := LoadDnDCharacter(userID)
|
||||
if dndChar == nil {
|
||||
|
||||
@@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{
|
||||
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
||||
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
||||
func rollZoneIngredient(zoneTier int) *AdvItem {
|
||||
if rand.Float64() >= advIngredientDropChance {
|
||||
chance := advIngredientDropChance
|
||||
if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen
|
||||
chance *= m
|
||||
}
|
||||
if rand.Float64() >= chance {
|
||||
return nil
|
||||
}
|
||||
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||||
@@ -517,6 +521,13 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster
|
||||
extra = append(extra, line)
|
||||
}
|
||||
}
|
||||
// The Hollow King campaign (N5/D1). Elites carry the scattered pages; the
|
||||
// gating boss gets an epilogue instead (D1b), not a page.
|
||||
if isElite {
|
||||
if line := p.maybeDropJournalPage(userID, nil); line != "" {
|
||||
extra = append(extra, line)
|
||||
}
|
||||
}
|
||||
trailer := strings.Join(extra, "\n")
|
||||
|
||||
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
||||
|
||||
@@ -323,7 +323,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
||||
// but only when this run is the active expedition's current run so
|
||||
// a standalone (non-expedition) stale run still reaps cleanly.
|
||||
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
|
||||
forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)")
|
||||
forceExtractExpeditionForRunLoss(userID, lossIdleTimeout)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
290
internal/plugin/exercise_prod_test.go
Normal file
290
internal/plugin/exercise_prod_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
//go:build prodexercise
|
||||
|
||||
package plugin
|
||||
|
||||
// Headless smoke-exercise of the N-series features (parties aside — those have
|
||||
// their own sim path) against a COPY of the prod DB. Nothing here touches the
|
||||
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
|
||||
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
|
||||
// before opening it, so even the copy is left pristine.
|
||||
//
|
||||
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
|
||||
//
|
||||
// Every outbound DM / room message a handler would have sent to Matrix is
|
||||
// captured via the existing MessageSink seam and dumped with t.Log, so the run
|
||||
// shows exactly what a player would see. Each feature is wrapped so a panic or
|
||||
// error in one is reported and the battery continues.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestExerciseNewFeaturesProd(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)
|
||||
|
||||
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
|
||||
// so the same DB copy exercises identically regardless of which week we run.
|
||||
simOmenDisabled = true
|
||||
simAutoArmEnabled = 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)
|
||||
}
|
||||
exercise := func(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Logf(" !! %s PANICKED: %v", name, r)
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
}()
|
||||
t.Logf("──────── %s ────────", name)
|
||||
fn()
|
||||
drain()
|
||||
}
|
||||
|
||||
chars := runnableChars(t)
|
||||
if len(chars) == 0 {
|
||||
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
|
||||
}
|
||||
t.Logf("runnable characters: %d", len(chars))
|
||||
for _, c := range chars {
|
||||
healToFull(t, c.uid)
|
||||
euro.Credit(c.uid, 5000, "exercise bankroll")
|
||||
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
|
||||
}
|
||||
ctxFor := func(uid id.UserID, body string) MessageContext {
|
||||
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
|
||||
}
|
||||
|
||||
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
|
||||
exercise("N7/B2 Renown — standing + prestige announce", func() {
|
||||
for _, c := range chars {
|
||||
xp, _ := loadRenownXP(c.uid)
|
||||
lvl := renownLevelForUser(c.uid)
|
||||
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
|
||||
}
|
||||
// Push the first character up a renown level to fire the announce path.
|
||||
c0 := chars[0]
|
||||
before, after, err := addRenownXP(c0.uid, 500)
|
||||
if err != nil {
|
||||
t.Logf(" addRenownXP: %v", err)
|
||||
return
|
||||
}
|
||||
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
|
||||
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
|
||||
})
|
||||
|
||||
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
|
||||
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
|
||||
boss, err := p.spawnWorldBoss("exercise-siege")
|
||||
if err != nil {
|
||||
t.Logf(" spawnWorldBoss: %v", err)
|
||||
}
|
||||
if boss != nil {
|
||||
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
|
||||
}
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
for _, c := range chars {
|
||||
t.Logf(" -- %s takes a bout --", c.uid)
|
||||
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
|
||||
}
|
||||
t.Logf(" -- pool after the raid --")
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
})
|
||||
|
||||
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
|
||||
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters for a duel; skipping")
|
||||
return
|
||||
}
|
||||
a, b := chars[0], chars[1]
|
||||
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
|
||||
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
|
||||
})
|
||||
|
||||
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
|
||||
exercise("N6/D3 Shadow — standing vs the rival", func() {
|
||||
for _, c := range chars {
|
||||
p.handleShadowCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
|
||||
exercise("N5/D1a Journal — collected campaign pages", func() {
|
||||
for _, c := range chars {
|
||||
p.handleJournalCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E3 Town registries ───────────────────────────────────────────────
|
||||
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
|
||||
p.handleTownCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
|
||||
})
|
||||
|
||||
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
|
||||
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
|
||||
for _, c := range chars {
|
||||
p.handleVaultCmd(ctxFor(c.uid, ""), "")
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
|
||||
exercise("N4/E2 Gifting — !give <item> @user", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters to gift; skipping")
|
||||
return
|
||||
}
|
||||
giver, receiver := chars[0], chars[1]
|
||||
item := firstInventoryItem(giver.uid)
|
||||
if item == "" {
|
||||
t.Logf(" %s has no inventory items to gift", giver.uid)
|
||||
return
|
||||
}
|
||||
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
|
||||
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
|
||||
p.handleGiveCmd(ctxFor(giver.uid, body))
|
||||
})
|
||||
|
||||
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
|
||||
exercise("N7/B4 Achievements — !achievements", func() {
|
||||
ach := NewAchievementsPlugin(nil, nil)
|
||||
ach.Sink = sink
|
||||
p.SetAchievements(ach)
|
||||
for _, c := range chars {
|
||||
ach.handleAchievements(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
type exChar struct {
|
||||
uid id.UserID
|
||||
class string
|
||||
level int
|
||||
}
|
||||
|
||||
// runnableChars lists characters that have both a completed dnd_character (class
|
||||
// set) and the legacy adventure_characters row the expedition/feature paths load.
|
||||
func runnableChars(t *testing.T) []exChar {
|
||||
t.Helper()
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT d.user_id, d.class, d.dnd_level
|
||||
FROM dnd_character d
|
||||
JOIN adventure_characters a ON a.user_id = d.user_id
|
||||
WHERE d.class != ''`)
|
||||
if err != nil {
|
||||
t.Fatalf("query chars: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []exChar
|
||||
for rows.Next() {
|
||||
var c exChar
|
||||
var uid string
|
||||
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.uid = id.UserID(uid)
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
|
||||
return out
|
||||
}
|
||||
|
||||
func healToFull(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Logf(" healToFull: load %s: %v", uid, err)
|
||||
return
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Logf(" healToFull: save %s: %v", uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func firstInventoryItem(uid id.UserID) string {
|
||||
d := db.Get()
|
||||
var name string
|
||||
// adventure_inventory holds the player's consumables/misc, one row per item.
|
||||
_ = d.QueryRow(
|
||||
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
|
||||
string(uid),
|
||||
).Scan(&name)
|
||||
return name
|
||||
}
|
||||
|
||||
func copyIfPresent(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return // wal/shm may be absent — fine
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func indent(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
for i, l := range lines {
|
||||
lines[i] = " " + l
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func ambientEventMonologue() ambientEvent {
|
||||
// it's cheap to test in isolation and free of package-level state.
|
||||
func ambientEvents() []ambientEvent {
|
||||
always := func(*Expedition) bool { return true }
|
||||
return []ambientEvent{
|
||||
events := []ambientEvent{
|
||||
ambientEventMonologue(),
|
||||
{
|
||||
Kind: "small_find",
|
||||
@@ -280,6 +280,19 @@ func ambientEvents() []ambientEvent {
|
||||
},
|
||||
},
|
||||
}
|
||||
// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
|
||||
// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
|
||||
// reward come from activeSeason(); the ambient seam is production-only, so
|
||||
// this never reaches the balance sim.
|
||||
if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
|
||||
events = append(events, ambientEvent{
|
||||
Kind: "season_visitor",
|
||||
Pool: s.Visitor.FlavorPool,
|
||||
Weight: s.Visitor.Weight,
|
||||
Eligible: func(*Expedition) bool { return true },
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
|
||||
@@ -395,6 +408,39 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
||||
return ""
|
||||
}
|
||||
return "+1 HP"
|
||||
case "season_visitor":
|
||||
// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
|
||||
// the season so the reward matches the current window even if it turned
|
||||
// over between pick and apply; bail quietly if it just ended.
|
||||
s, ok := activeSeason()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
v := s.Visitor
|
||||
uid := id.UserID(e.UserID)
|
||||
if err := addAdvInventoryItem(uid, AdvItem{
|
||||
Name: v.Keepsake,
|
||||
Type: "trophy",
|
||||
Tier: 1,
|
||||
Value: v.KeepsakeValue,
|
||||
}); err != nil {
|
||||
slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
|
||||
return ""
|
||||
}
|
||||
if v.Coins > 0 {
|
||||
if p.euro != nil {
|
||||
p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
|
||||
}
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET coins_earned = coins_earned + ?,
|
||||
last_activity = CURRENT_TIMESTAMP
|
||||
WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
|
||||
slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
|
||||
}
|
||||
return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
|
||||
}
|
||||
return fmt.Sprintf("You keep the %s.", v.Keepsake)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -44,11 +44,14 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
|
||||
threatLines []string
|
||||
milestoneLine []string
|
||||
narrativeBits []string
|
||||
journalPages int
|
||||
)
|
||||
for _, e := range entries {
|
||||
switch e.Type {
|
||||
case "walk":
|
||||
walks++
|
||||
case "journal":
|
||||
journalPages++
|
||||
case "harvest":
|
||||
// Only count successful gathers — failed rolls / errors are noise.
|
||||
if strings.Contains(e.Summary, "success") {
|
||||
@@ -115,6 +118,12 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
|
||||
b.WriteString("\n")
|
||||
bulleted = true
|
||||
}
|
||||
if react := twinBeeJournalReaction(prevDay, journalPages); react != "" {
|
||||
b.WriteString("• ")
|
||||
b.WriteString(react)
|
||||
b.WriteString("\n")
|
||||
bulleted = true
|
||||
}
|
||||
if !bulleted {
|
||||
// All entries were filtered out — fall back to the bare camp block.
|
||||
return ""
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
internal/plugin/expedition_companion_cmd.go
Normal file
115
internal/plugin/expedition_companion_cmd.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The player-facing surface for hiring Pete.
|
||||
//
|
||||
// !expedition hire [class] (leader, Day 1 — auto-fills the missing role)
|
||||
// !expedition dismiss (leader — sends him home, no refund)
|
||||
//
|
||||
// He is hired on Day 1 like any other companion, because a body that materializes
|
||||
// three rooms in is not a party member, it is a cheat code. See
|
||||
// adventure_companion.go for why he is an NPC seat and not a player.
|
||||
|
||||
// expeditionCmdHire brings the correspondent along.
|
||||
func (p *AdventurePlugin) expeditionCmdHire(ctx MessageContext, rest string) error {
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition. `!expedition start <zone>` first.")
|
||||
}
|
||||
if !isLeader {
|
||||
return p.SendDM(ctx.Sender, "Only the party leader hires. You're along for the ride.")
|
||||
}
|
||||
if !inviteWindowOpen(exp) {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"This expedition has already set off — Pete joins on Day 1 or not at all.")
|
||||
}
|
||||
if companionSeated(exp.ID) {
|
||||
return p.SendDM(ctx.Sender, "Pete's already with you. `!expedition party` to see the roster.")
|
||||
}
|
||||
if p.euro == nil {
|
||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
||||
}
|
||||
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
level := companionPartyLevel(exp.ID)
|
||||
|
||||
class, explicit := parseCompanionClass(rest)
|
||||
if !explicit {
|
||||
if arg := strings.TrimSpace(rest); arg != "" && !strings.EqualFold(arg, "auto") {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Don't know the class %q. Name one, or just `!expedition hire` and he'll fill whatever you're missing.", arg))
|
||||
}
|
||||
class = companionRoleFill(companionPartyClasses(exp.ID))
|
||||
}
|
||||
|
||||
cost := float64(companionHireCost(level, zone.Tier))
|
||||
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Pete doesn't work for free. His fee for **%s** is **%d** — you have **%.0f**.",
|
||||
zone.Display, int(cost), balance))
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, cost, "expedition: hired Pete") {
|
||||
return p.SendDM(ctx.Sender, "Couldn't debit Pete's fee (try again).")
|
||||
}
|
||||
|
||||
if err := hireCompanion(exp.ID, class, level); err != nil {
|
||||
p.euro.Credit(ctx.Sender, cost, "expedition: Pete hire refund")
|
||||
return p.SendDM(ctx.Sender, companionRefusalText(err))
|
||||
}
|
||||
|
||||
emitCompanionHireFact(ctx.Sender, class, level, zone)
|
||||
|
||||
ci, _ := classInfo(class)
|
||||
filled := "You asked for a " + ci.Display + "."
|
||||
if !explicit {
|
||||
filled = "He's filling the hole in your roster — **" + ci.Display + "**."
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"📝 **Pete's coming with you into %s.** %s He's **level %d**, and his fee was **%d**.\n\n"+
|
||||
"He fights his own turns — you don't command him. He takes no loot and no XP; he's here for the story.\n"+
|
||||
"`!expedition dismiss` sends him home (no refund — he's already packed).",
|
||||
zone.Display, filled, level, int(cost)))
|
||||
}
|
||||
|
||||
// expeditionCmdDismiss sends him home mid-run.
|
||||
func (p *AdventurePlugin) expeditionCmdDismiss(ctx MessageContext) error {
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition.")
|
||||
}
|
||||
if !isLeader {
|
||||
return p.SendDM(ctx.Sender, "Only the party leader can dismiss him.")
|
||||
}
|
||||
if err := dismissCompanion(exp.ID); err != nil {
|
||||
return p.SendDM(ctx.Sender, companionRefusalText(err))
|
||||
}
|
||||
return p.SendDM(ctx.Sender,
|
||||
"📝 **Pete heads back.** He got what he came for. The fee stays spent.")
|
||||
}
|
||||
|
||||
// companionRefusalText turns the companion layer's sentinel errors into copy.
|
||||
func companionRefusalText(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrCompanionAlreadyHired):
|
||||
return "Pete's already with you."
|
||||
case errors.Is(err, ErrCompanionOnAssignment):
|
||||
return "Pete's out on assignment with another party. He'll be back."
|
||||
case errors.Is(err, ErrCompanionNotHired):
|
||||
return "Pete isn't with you."
|
||||
case errors.Is(err, ErrPartyFull):
|
||||
return "No room — your party's full. Someone has to `!expedition leave` first."
|
||||
default:
|
||||
return "Couldn't hire Pete: " + err.Error()
|
||||
}
|
||||
}
|
||||
@@ -82,30 +82,126 @@ func partyMembers(expeditionID string) ([]PartyMember, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what
|
||||
// the fan-out seams (digest, briefing, recap) want: a solo expedition yields
|
||||
// just the owner, so a caller can loop unconditionally.
|
||||
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
|
||||
members, err := partyMembers(expeditionID)
|
||||
// PartySeatKind is what a seat *is*. The three answers differ in ways that matter
|
||||
// at almost every seam, and conflating any two of them has already cost us a bug:
|
||||
// a companion is a seat but not a mouth and not a mailbox; a leader owns the
|
||||
// expedition row that everyone else references.
|
||||
type PartySeatKind int
|
||||
|
||||
const (
|
||||
SeatLeader PartySeatKind = iota
|
||||
SeatMember
|
||||
SeatCompanion
|
||||
)
|
||||
|
||||
// PartySeat is one body on an expedition.
|
||||
type PartySeat struct {
|
||||
UserID id.UserID
|
||||
Kind PartySeatKind
|
||||
}
|
||||
|
||||
// IsHuman reports whether there is a person behind this seat — someone who can be
|
||||
// DM'd, can earn loot, eats supplies, and can die.
|
||||
func (s PartySeat) IsHuman() bool { return s.Kind != SeatCompanion }
|
||||
|
||||
// expeditionParty is THE answer to "who is on this expedition". Every other view
|
||||
// — who gets mail, who sits down in a fight, who eats — is derived from it.
|
||||
//
|
||||
// It ALWAYS includes the owner. That is the whole point, and it is not a
|
||||
// convenience: a solo expedition has **no expedition_party rows at all** (the
|
||||
// roster only materializes on the first invite — see partyMembers), so any code
|
||||
// that answers "who is in this party?" by reading the roster table gets *nobody*
|
||||
// for a solo player, and then quietly falls back to whatever looked sensible at
|
||||
// the call site.
|
||||
//
|
||||
// That is not hypothetical. It is exactly how the hired companion came out at
|
||||
// **level 1** for every solo player — the one player the feature exists for. The
|
||||
// level was averaged over the party; the party read as empty; the fallback was 1.
|
||||
// He then walked into a tier-4 zone as a level-1 body, died on contact, and left
|
||||
// the leader fighting a boss that had been inflated on his account. A 1500-run
|
||||
// sweep is what found it, because nothing else could.
|
||||
//
|
||||
// So: there is no way to ask this function for the party and be handed an empty
|
||||
// list. If the expedition exists, it has at least a leader.
|
||||
func expeditionParty(expeditionID, ownerID string) ([]PartySeat, error) {
|
||||
rows, err := partyMembers(expeditionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return []id.UserID{id.UserID(ownerID)}, nil
|
||||
if len(rows) == 0 {
|
||||
// Solo: no roster rows. The owner IS the party.
|
||||
if ownerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []PartySeat{{UserID: id.UserID(ownerID), Kind: SeatLeader}}, nil
|
||||
}
|
||||
out := make([]id.UserID, 0, len(members))
|
||||
for _, m := range members {
|
||||
out = append(out, id.UserID(m.UserID))
|
||||
out := make([]PartySeat, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
kind := SeatMember
|
||||
switch {
|
||||
case isCompanionUser(m.UserID):
|
||||
kind = SeatCompanion
|
||||
case m.IsLeader():
|
||||
kind = SeatLeader
|
||||
}
|
||||
out = append(out, PartySeat{UserID: id.UserID(m.UserID), Kind: kind})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// partySize is the number of seated players: 1 for a solo expedition.
|
||||
// partyHumans is the party minus the companion: everyone with a person behind
|
||||
// them. This is the set that gets mail, earns loot, eats supplies, and can die.
|
||||
func partyHumans(expeditionID, ownerID string) ([]PartySeat, error) {
|
||||
seats, err := expeditionParty(expeditionID, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := seats[:0]
|
||||
for _, s := range seats {
|
||||
if s.IsHuman() {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// partyMemberIDs is the whole party as ids, leader first — every body, companion
|
||||
// included. Callers that want only people want partyHumans.
|
||||
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
|
||||
seats, err := expeditionParty(expeditionID, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]id.UserID, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
out = append(out, s.UserID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// partySize is the number of seated *players*: 1 for a solo expedition.
|
||||
//
|
||||
// A hired companion is not counted, and both of this function's consumers want
|
||||
// it that way:
|
||||
//
|
||||
// - expeditionBurnRatePct scales the daily supply burn by party size. Pete
|
||||
// never bought a pack — members buy their own on !expedition accept, and he
|
||||
// accepts nothing — so counting him would bill the leader a 60% higher burn
|
||||
// for a mouth that brought its own rations. He is on expenses.
|
||||
// - the "your party is still waiting on you" gate blocks a leader from
|
||||
// starting a new run while an extracted party is pending. A roster holding
|
||||
// nobody but Pete is not a party waiting on anyone, and counting him would
|
||||
// lock the leader out of the game until they abandoned the run.
|
||||
//
|
||||
// The combat roster is a different question with a different answer: Pete IS a
|
||||
// body in the fight, so CombatSession.RosterSize() counts him and the enemy-HP
|
||||
// scalar feels him. Seats and mouths are not the same set.
|
||||
func partySize(expeditionID string) (int, error) {
|
||||
var n int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
|
||||
expeditionID).Scan(&n)
|
||||
`SELECT COUNT(*) FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id <> ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -246,6 +246,10 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
||||
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
|
||||
}
|
||||
for _, m := range members {
|
||||
if isCompanionUser(m.UserID) {
|
||||
b.WriteString(companionRosterLine(exp.ID))
|
||||
continue
|
||||
}
|
||||
role := "member"
|
||||
if m.IsLeader() {
|
||||
role = "leader"
|
||||
@@ -260,6 +264,9 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
||||
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
|
||||
if inviteWindowOpen(exp) {
|
||||
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
|
||||
if !companionSeated(exp.ID) {
|
||||
b.WriteString("\n_Short a body? `!expedition hire` brings Pete along._")
|
||||
}
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
@@ -108,13 +108,45 @@ func seatedExpeditionFor(userID id.UserID) (*Expedition, error) {
|
||||
// degrades to the owner rather than dropping the message — a player who misses
|
||||
// their briefing because SQLite hiccuped is a worse outcome than a member who
|
||||
// misses theirs.
|
||||
//
|
||||
// A hired companion is dropped here, and this is the chokepoint that keeps him
|
||||
// out of every DM seam at once: the briefing, the recap, the digest, the
|
||||
// extraction notice — and, crucially, the per-member side effects that ride the
|
||||
// fan-out rather than the message. maybeRollPetArrivalOnEmerge would offer Pete
|
||||
// a pet and park a pending interaction awaiting a reply that never comes;
|
||||
// maybeFireAnchoredEvent would claim him a daily event slot and DM him a
|
||||
// choice. He is not a person; he does not get mail. See isCompanionSeat.
|
||||
func expeditionAudience(e *Expedition) []id.UserID {
|
||||
if e == nil || e.UserID == "" {
|
||||
return nil
|
||||
}
|
||||
seats, err := partyHumans(e.ID, e.UserID)
|
||||
if err != nil {
|
||||
slog.Warn("expedition: party roster read failed, DMing owner only",
|
||||
"expedition", e.ID, "err", err)
|
||||
return []id.UserID{id.UserID(e.UserID)}
|
||||
}
|
||||
out := make([]id.UserID, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
out = append(out, s.UserID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// expeditionSeats is every body that sits down in a fight: the whole roster,
|
||||
// hired companion included. It is deliberately NOT expeditionAudience — that one
|
||||
// drops the companion because he does not get mail, and a combat roster built
|
||||
// from it would seat everyone the leader paid for except the one he paid for.
|
||||
//
|
||||
// Mail and seats are different sets. Anything that sends is an audience;
|
||||
// anything that fights is a seat.
|
||||
func expeditionSeats(e *Expedition) []id.UserID {
|
||||
if e == nil || e.UserID == "" {
|
||||
return nil
|
||||
}
|
||||
ids, err := partyMemberIDs(e.ID, e.UserID)
|
||||
if err != nil {
|
||||
slog.Warn("expedition: party roster read failed, DMing owner only",
|
||||
slog.Warn("expedition: party roster read failed, seating owner only",
|
||||
"expedition", e.ID, "err", err)
|
||||
return []id.UserID{id.UserID(e.UserID)}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") ==
|
||||
type SimRunner struct {
|
||||
P *AdventurePlugin
|
||||
Euro *EuroPlugin
|
||||
// Companion hires Pete into the party on Day 1: "" = none, "auto" = fill the
|
||||
// missing role, or an explicit class id. He takes a combat seat and no loot,
|
||||
// which is exactly the thing the sweep is here to measure — whether an extra
|
||||
// below-median body lifts the trailing case without carrying it.
|
||||
Companion string
|
||||
}
|
||||
|
||||
// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs
|
||||
@@ -49,6 +54,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
|
||||
// etc. fire the way a competent prod player would set them up.
|
||||
// Without this the sim under-counts class survival.
|
||||
simAutoArmEnabled = true
|
||||
// N7/B3 — neutralize the weekly Omen so corpus results don't depend on which
|
||||
// wall-clock week the sweep runs in (the sim's synthetic clock never reaches
|
||||
// activeOmen's time.Now()).
|
||||
simOmenDisabled = true
|
||||
euro := &EuroPlugin{}
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
return &SimRunner{P: p, Euro: euro}, nil
|
||||
@@ -127,6 +136,38 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// PrepareRealCharacter readies an already-persisted character (loaded from a
|
||||
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
|
||||
// nothing: the character keeps its real race/class/subclass/level, ability
|
||||
// scores, equipment, spellbook and inventory. It only (a) heals to full — a
|
||||
// player rests before heading out, so we don't handicap a wounded prod snapshot
|
||||
// — and (b) tops the bankroll up to `bank` so the "heavy" supply preset always
|
||||
// affords itself regardless of the player's live coin balance. Returns the
|
||||
// loaded character. The DB copy is disposable; the live prod file is untouched.
|
||||
func (s *SimRunner) PrepareRealCharacter(uid id.UserID, bank float64) (*DnDCharacter, error) {
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
|
||||
}
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no character for %s in db copy", uid)
|
||||
}
|
||||
if c.Class == "" {
|
||||
return nil, fmt.Errorf("%s has no class (setup incomplete) — nothing to simulate", uid)
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
|
||||
}
|
||||
if bal := s.Euro.GetBalance(uid); bal < bank {
|
||||
s.Euro.Credit(uid, bank-bal, "expedition-sim real-char bankroll top-up")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
||||
// + a couple offensive items into the synthetic player's inventory so
|
||||
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
||||
@@ -364,7 +405,42 @@ type SimCombatSummary struct {
|
||||
// SetSimIncludeTrace(true) has been called, and only on the LAST
|
||||
// combat per expedition (the boss room) to keep JSONL size bounded.
|
||||
// Used by J2 caster-survival analysis.
|
||||
Events []CombatEvent `json:",omitempty"`
|
||||
Events []simTraceEvent `json:",omitempty"`
|
||||
}
|
||||
|
||||
// simTraceEvent is CombatEvent with the seat ALWAYS emitted.
|
||||
//
|
||||
// CombatEvent.Seat is `omitempty` for wire-compat reasons that are correct for
|
||||
// persistence and wrong for a trace: seat 0 and "no seat" serialize identically,
|
||||
// so a party trace silently reads as a solo fight. That is not hypothetical — it
|
||||
// is what made a hired companion who never took a turn look, in the JSON, like a
|
||||
// fight that only ever had one seat, and it cost an hour of chasing the wrong bug.
|
||||
// A diagnostic that cannot tell you who acted is not a diagnostic.
|
||||
type simTraceEvent struct {
|
||||
Round int
|
||||
Seat int // always present, even when 0
|
||||
Phase string
|
||||
Actor string
|
||||
Action string
|
||||
Damage int
|
||||
PlayerHP int
|
||||
EnemyHP int
|
||||
Roll int
|
||||
RollAgainst int
|
||||
Desc string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// simTraceEvents converts a TurnLog for the trace.
|
||||
func simTraceEvents(events []CombatEvent) []simTraceEvent {
|
||||
out := make([]simTraceEvent, len(events))
|
||||
for i, e := range events {
|
||||
out[i] = simTraceEvent{
|
||||
Round: e.Round, Seat: e.Seat, Phase: e.Phase, Actor: e.Actor, Action: e.Action,
|
||||
Damage: e.Damage, PlayerHP: e.PlayerHP, EnemyHP: e.EnemyHP,
|
||||
Roll: e.Roll, RollAgainst: e.RollAgainst, Desc: e.Desc,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// simIncludeTrace gates per-round event capture on SimCombatSummary.
|
||||
@@ -480,6 +556,22 @@ func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneI
|
||||
return res, fmt.Errorf("expedition vanished while seating the party")
|
||||
}
|
||||
}
|
||||
// Hire the companion on Day 1, after the humans are seated so the role fill
|
||||
// sees the party it is filling a hole in. He is not in `members` — he buys no
|
||||
// packs and takes no seat in the human roster — but fightRoster reads
|
||||
// expeditionSeats, so he shows up in every fight from here on.
|
||||
if s.Companion != "" {
|
||||
class, ok := parseCompanionClass(s.Companion)
|
||||
if !ok {
|
||||
class = companionRoleFill(companionPartyClasses(exp.ID))
|
||||
}
|
||||
if err := hireCompanion(exp.ID, class, companionPartyLevel(exp.ID)); err != nil {
|
||||
res.Outcome = "halted"
|
||||
res.StopCode = "companion:" + err.Error()
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
roster := append([]id.UserID{uid}, members...)
|
||||
res.PartySize = len(roster)
|
||||
for _, m := range members {
|
||||
@@ -906,7 +998,7 @@ func simCombatSummaries(uid id.UserID) []SimCombatSummary {
|
||||
out = append(out, s)
|
||||
}
|
||||
if simIncludeTrace && len(out) > 0 {
|
||||
out[len(out)-1].Events = lastEvents
|
||||
out[len(out)-1].Events = simTraceEvents(lastEvents)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1002,6 +1094,18 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
|
||||
turn.Sender = id.UserID(cur.seatUserID(seat))
|
||||
}
|
||||
|
||||
// An engine-driven seat is never dispatched as a chat command. There is no
|
||||
// character for the handlers to load, and a command arriving from that
|
||||
// seat's id reads to beginCombatTurn as a player returning to the keyboard
|
||||
// — which is how the driver used to clear the very latch that was moving
|
||||
// the seat. Drive it directly instead.
|
||||
if cur.seatIsEngineDriven(seat) {
|
||||
if err := p.driveEngineSeat(cur, seat); err != nil {
|
||||
return false, fmt.Errorf("engine seat %d: %w", seat, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
|
||||
var dispatchErr error
|
||||
switch kind {
|
||||
@@ -1106,8 +1210,16 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio
|
||||
// which is seat 0. Driving a party member's turn off the leader's HP would heal
|
||||
// the wrong person and re-arm the wrong aura.
|
||||
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
if c == nil || sess == nil {
|
||||
if sess == nil {
|
||||
return "attack", ""
|
||||
}
|
||||
// seatPickSheet, not LoadDnDCharacter: the hired companion has no character row
|
||||
// and never will, so the raw load returned nil for him and this function bailed
|
||||
// to "attack" on its first line — every turn, for the whole fight. That one line
|
||||
// is why a hired Cleric swung a mace while the party died. He fights off the
|
||||
// same synthetic sheet the rest of his seat is built from.
|
||||
c := seatPickSheet(sess, uid)
|
||||
if c == nil {
|
||||
return "attack", ""
|
||||
}
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
@@ -1121,6 +1233,20 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
}
|
||||
}
|
||||
}
|
||||
// §1 — a healer with a hurt friend heals the friend. This is what a competent
|
||||
// player does, and until the engine could target another seat it was not an
|
||||
// option the picker even had. It runs before the damage picks: a party member
|
||||
// about to die is a more urgent use of a slot than another Fireball.
|
||||
// A healer heals whoever is worst off — the friend bleeding out beside them, or
|
||||
// themselves. An empty target is a self-cast, and `!cast <spell>` with no
|
||||
// @mention is exactly how a player writes that.
|
||||
if id, target := simPickHeal(c, uid, sess, seat); id != "" {
|
||||
if target == "" {
|
||||
return "cast", id
|
||||
}
|
||||
return "cast", id + " @" + target
|
||||
}
|
||||
|
||||
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
|
||||
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
||||
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
||||
@@ -1128,7 +1254,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
// otherwise never spends an L2 slot on it. Force the pick once
|
||||
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
||||
// it's not a damage option.
|
||||
if id := simPickSpiritualWeapon(c, uid, st); id != "" {
|
||||
if id := simPickSpiritualWeapon(c, sess, seat, uid, st); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
// Once a concentration aura is up, a competent caster maintains it and
|
||||
@@ -1136,7 +1262,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
// slot to re-arm the same aura — so the picker excludes concentration
|
||||
// spells while one is active.
|
||||
auraActive := st.ConcentrationDmg > 0
|
||||
if id := simPickSpell(c, uid, auraActive); id != "" {
|
||||
if id := simPickSpell(c, sess, seat, uid, auraActive); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
}
|
||||
@@ -1171,14 +1297,14 @@ func simMartialFirstClass(class DnDClass) bool {
|
||||
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
|
||||
// not worth burning the bigger slot's damage potential elsewhere; sim
|
||||
// behaves like a competent player and saves the high slot.
|
||||
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string {
|
||||
func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, st ActorStatuses) string {
|
||||
if c == nil || c.Class != ClassCleric {
|
||||
return ""
|
||||
}
|
||||
if st.BuffSpiritProc > 0 {
|
||||
return ""
|
||||
}
|
||||
known, err := listKnownSpells(uid)
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -1192,7 +1318,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
||||
if !prepared {
|
||||
return ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
const simMaxSlot = 5
|
||||
for sl := 2; sl <= simMaxSlot; sl++ {
|
||||
pair, ok := slots[sl]
|
||||
@@ -1225,12 +1351,111 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
||||
// - Among feasible candidates, prefer higher slot level (preserves
|
||||
// high-slot supremacy and burns the big slots first); tie-break on
|
||||
// expected damage from the dice string.
|
||||
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
|
||||
known, err := listKnownSpells(uid)
|
||||
//
|
||||
// simHealAllyThresholdPct is how hurt a friend has to be before a healer spends a
|
||||
// slot on them rather than on damage. Deliberately lower than the self-heal
|
||||
// threshold: a heal is a wasted turn if the target was going to live anyway, and
|
||||
// the party's damage output is what ends the fight.
|
||||
const simHealAllyThresholdPct = 45
|
||||
|
||||
// simPickHeal returns the heal spell a competent healer would cast this turn, and
|
||||
// the seat to put it on — which may be the healer's own. An empty spell id means
|
||||
// nobody needs healing, or this character cannot heal.
|
||||
//
|
||||
// Returning target "" means "cast it on yourself": the caller drops the @mention,
|
||||
// and the engine's ordinary self-heal path takes it.
|
||||
//
|
||||
// It considers the caster's own seat, and it runs for a solo fight, and BOTH of
|
||||
// those are recent. Until now the rule skipped `i == seat` and bailed on
|
||||
// `!sess.IsParty()`, which together meant something nobody had said out loud: **no
|
||||
// autopiloted caster in this game had ever cast a heal on themselves.** A solo
|
||||
// cleric carried cure_wounds for a whole 30-room run and used it exactly never,
|
||||
// while dying with a full pool of level-1 slots. That is a strong candidate for
|
||||
// why the class corpus has cleric at 46–56% against fighter's 82%
|
||||
// ([[project_d8prereq_baseline]], §6 of the combat-engine plan) — the picker was
|
||||
// not playing the class.
|
||||
//
|
||||
// The healer heals whoever is worst off. Usually that is the friend bleeding out
|
||||
// next to them. Sometimes it is them.
|
||||
func simPickHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
|
||||
if sess == nil || c == nil || !isSpellcaster(c) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Who is worst off? Only living seats — the engine will not raise the downed,
|
||||
// and a heal aimed at a corpse is a lost turn.
|
||||
worst, worstPct := -1, 101
|
||||
for i := range sess.RosterSize() {
|
||||
hp, hpMax := sess.seatHP(i), sess.seatHPMax(i)
|
||||
if hp <= 0 || hpMax <= 0 {
|
||||
continue
|
||||
}
|
||||
if pct := hp * 100 / hpMax; pct < worstPct {
|
||||
worst, worstPct = i, pct
|
||||
}
|
||||
}
|
||||
if worst < 0 || worstPct >= simHealAllyThresholdPct {
|
||||
return "", ""
|
||||
}
|
||||
// Healing yourself is not an ally-target at all — it is the plain self-heal the
|
||||
// engine has always had. Hand the caller no target and let it say so.
|
||||
if worst == seat {
|
||||
return simPickHealSpell(c, uid, sess, seat), ""
|
||||
}
|
||||
|
||||
best := simPickHealSpell(c, uid, sess, seat)
|
||||
if best == "" {
|
||||
return "", ""
|
||||
}
|
||||
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
|
||||
// the seats in this fight, so this round-trips without a room lookup.
|
||||
return best, id.UserID(sess.seatUserID(worst)).Localpart()
|
||||
}
|
||||
|
||||
// simPickHealSpell is the cheapest heal this caster can actually cast right now,
|
||||
// or "". Burning a 5th-level slot to top somebody up is not what a competent
|
||||
// player does, so it takes the lowest castable slot.
|
||||
func simPickHealSpell(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) string {
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
best, bestLevel := "", 99
|
||||
for _, k := range known {
|
||||
if !k.Prepared {
|
||||
continue
|
||||
}
|
||||
sp, ok := lookupSpell(k.SpellID)
|
||||
if !ok || sp.Effect != EffectSpellHeal || sp.CastTime == CastReaction {
|
||||
continue
|
||||
}
|
||||
onList := false
|
||||
for _, cl := range sp.Classes {
|
||||
if cl == c.Class {
|
||||
onList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !onList || sp.Level == 0 {
|
||||
continue // heals are slot spells; a level-0 "heal" is not a thing we cast
|
||||
}
|
||||
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
|
||||
continue
|
||||
}
|
||||
if sp.Level < bestLevel {
|
||||
best, bestLevel = sp.ID, sp.Level
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil || len(known) == 0 {
|
||||
return ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
type cand struct {
|
||||
id string
|
||||
slot int
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
338
internal/plugin/pete.go
Normal file
338
internal/plugin/pete.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// This file is gogobee's game-side entry to Pete's adventure news. Plugins call
|
||||
// emitFact at event chokepoints; it enforces the kill-switch + per-player
|
||||
// opt-out, then hands a durable fact to peteclient. gogobee supplies facts only
|
||||
// — Pete owns the voice. See pete_adventure_news_plan.md / _voice.md.
|
||||
|
||||
const (
|
||||
newsEnabledKey = "adventure_news_enabled"
|
||||
newsGUIDSaltKey = "adventure_news_guid_salt"
|
||||
anonName = "an adventurer"
|
||||
)
|
||||
|
||||
// newsEmissionOn is the runtime kill-switch (default on). An operator flips it
|
||||
// with `!news off` without a redeploy; FEATURE_PETE_NEWS is the source-level
|
||||
// master switch checked separately by peteclient.Enabled. Persisted in the
|
||||
// durable news_config table (NOT api_cache, which RunMaintenance prunes).
|
||||
func newsEmissionOn() bool {
|
||||
var v string
|
||||
err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsEnabledKey).Scan(&v)
|
||||
if err != nil {
|
||||
return true // no row → default on
|
||||
}
|
||||
return v != "0"
|
||||
}
|
||||
|
||||
// setNewsEmission persists the runtime kill-switch.
|
||||
func setNewsEmission(on bool) {
|
||||
v := "1"
|
||||
if !on {
|
||||
v = "0"
|
||||
}
|
||||
db.Exec("news emission set",
|
||||
`INSERT INTO news_config (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
newsEnabledKey, v)
|
||||
}
|
||||
|
||||
// isNewsOptedOut reports whether a player asked not to be named in the news.
|
||||
func isNewsOptedOut(userID id.UserID) bool {
|
||||
var one int
|
||||
err := db.Get().QueryRow(`SELECT 1 FROM news_optout WHERE user_id = ?`, string(userID)).Scan(&one)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// setNewsOptout records or clears a player's opt-out. Opting out anonymizes
|
||||
// their name in future dispatches ("an adventurer…"); it does not stop events
|
||||
// being reported. Idempotent either direction.
|
||||
func setNewsOptout(userID id.UserID, out bool) {
|
||||
if out {
|
||||
db.Exec("news optout set",
|
||||
`INSERT OR IGNORE INTO news_optout (user_id, opted_out_at) VALUES (?, unixepoch())`,
|
||||
string(userID))
|
||||
return
|
||||
}
|
||||
db.Exec("news optout clear", `DELETE FROM news_optout WHERE user_id = ?`, string(userID))
|
||||
}
|
||||
|
||||
var (
|
||||
guidSaltOnce sync.Once
|
||||
guidSalt []byte
|
||||
)
|
||||
|
||||
// newsGUIDSalt returns the secret, server-side salt that keys every public event
|
||||
// token. It is 32 random bytes, generated once and persisted in the durable
|
||||
// news_config table, cached in-process. Secret from anyone outside the server
|
||||
// (external users never see it), stable across restarts (so a re-emit or the
|
||||
// cold-start backfill reproduces the same GUID). No operator action needed.
|
||||
func newsGUIDSalt() []byte {
|
||||
guidSaltOnce.Do(func() {
|
||||
var stored string
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&stored); err == nil {
|
||||
if b, e := hex.DecodeString(stored); e == nil && len(b) > 0 {
|
||||
guidSalt = b
|
||||
return
|
||||
}
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand should never fail; if it does, refuse to fall back to a
|
||||
// predictable salt (that would reopen the enumeration attack). Leave the
|
||||
// salt empty and let eventToken sort it out.
|
||||
return
|
||||
}
|
||||
salt := hex.EncodeToString(b)
|
||||
// Persist. OR IGNORE so a concurrent first-writer wins cleanly; we then
|
||||
// re-read the row so every caller agrees on the same salt.
|
||||
db.Exec("news guid salt seed",
|
||||
`INSERT OR IGNORE INTO news_config (key, value) VALUES (?, ?)`, newsGUIDSaltKey, salt)
|
||||
_ = db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt)
|
||||
guidSalt, _ = hex.DecodeString(salt)
|
||||
})
|
||||
return guidSalt
|
||||
}
|
||||
|
||||
// eventToken is the per-event, one-way identifier embedded in a public GUID
|
||||
// (which becomes a PUBLIC permalink path on Pete). It is HMAC-SHA256 over the
|
||||
// Matrix user id AND an event-specific discriminator, keyed by the secret
|
||||
// newsGUIDSalt.
|
||||
//
|
||||
// Two properties matter and both come from HMAC being a PRF under a secret key:
|
||||
// - Not computable from a Matrix handle. An attacker who knows @alice:server
|
||||
// cannot derive any of her tokens without the salt — so her events (including
|
||||
// ones anonymized after `!news optout`) can't be enumerated from her handle.
|
||||
// - Per-event, so tokens are mutually unlinkable. Even for the same user, two
|
||||
// events yield unrelated tokens; a story that names her (opted-in, or a duel
|
||||
// opponent) exposes only that one event's token and can't be walked back to
|
||||
// her other, anonymized events.
|
||||
//
|
||||
// The discriminator must uniquely identify the logical event so a re-emit or the
|
||||
// backfill reproduces the same token (idempotency rides on the GUID).
|
||||
func eventToken(userID id.UserID, discriminator string) string {
|
||||
mac := hmac.New(sha256.New, newsGUIDSalt())
|
||||
mac.Write([]byte(userID))
|
||||
mac.Write([]byte{0}) // domain separator: keep id and discriminator from bleeding
|
||||
mac.Write([]byte(discriminator))
|
||||
return hex.EncodeToString(mac.Sum(nil)[:9]) // 18 hex chars — ample, non-reversible
|
||||
}
|
||||
|
||||
// charName returns a player's in-game character name, never their Matrix handle.
|
||||
// Empty when unknown — callers skip the emit rather than fall back to a handle.
|
||||
func charName(userID id.UserID) string {
|
||||
name, _ := loadDisplayName(userID)
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// charLevel returns a player's current character level for a news Fact, or 0 if
|
||||
// no character loads (Level is omitempty, so 0 is dropped from the payload).
|
||||
func charLevel(userID id.UserID) int {
|
||||
if dc, _ := LoadDnDCharacter(userID); dc != nil {
|
||||
return dc.Level
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// classRaceLabel renders a character's race + class for the arrival fact, e.g.
|
||||
// "Elf Ranger".
|
||||
func classRaceLabel(c *DnDCharacter) string {
|
||||
ri, _ := raceInfo(c.Race)
|
||||
ci, _ := classInfo(c.Class)
|
||||
return strings.TrimSpace(ri.Display + " " + ci.Display)
|
||||
}
|
||||
|
||||
// emitFact is the single game-side entry to Pete's news. It enforces the runtime
|
||||
// kill-switch and per-player opt-out (anonymize the name, never drop the event),
|
||||
// derives the actors allow-list from the FINAL names (so Pete's fact-guard and
|
||||
// the rendered content agree), then durably queues the fact.
|
||||
//
|
||||
// subjectUser/opponentUser are the Matrix users behind Subject/Opponent (either
|
||||
// may be empty for realm-level events). They are used ONLY for the opt-out
|
||||
// lookup and are never sent.
|
||||
func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) {
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
if subjectUser != "" && isNewsOptedOut(subjectUser) {
|
||||
f.Subject = anonName
|
||||
}
|
||||
if opponentUser != "" && isNewsOptedOut(opponentUser) {
|
||||
f.Opponent = anonName
|
||||
}
|
||||
var actors []string
|
||||
for _, n := range []string{f.Subject, f.Opponent} {
|
||||
if n != "" {
|
||||
actors = append(actors, n)
|
||||
}
|
||||
}
|
||||
f.Actors = actors
|
||||
peteclient.Emit(f)
|
||||
}
|
||||
|
||||
// handleNewsCmd backs `!news`. Players toggle whether they're named in Pete's
|
||||
// dispatches; admins flip the realm-wide emission kill-switch. Replies in-room
|
||||
// so the effect is visible where it was invoked.
|
||||
//
|
||||
// !news → show your status + what the command does
|
||||
// !news optout → anonymize your name in future dispatches
|
||||
// !news optin → be named again
|
||||
// !news on|off → (admin) master kill-switch for all emission
|
||||
func (p *AdventurePlugin) handleNewsCmd(ctx MessageContext) error {
|
||||
arg := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "news")))
|
||||
switch arg {
|
||||
case "optout", "opt-out", "anonymize", "anon":
|
||||
setNewsOptout(ctx.Sender, true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Done — Pete will refer to you as \"an adventurer\" in the news from now on. `!news optin` to be named again.")
|
||||
case "optin", "opt-in":
|
||||
setNewsOptout(ctx.Sender, false)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"You're back on the record — Pete will use your character name in future dispatches.")
|
||||
case "on", "enable":
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
setNewsEmission(true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "📣 Adventure news emission is now ON.")
|
||||
case "off", "disable":
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
setNewsEmission(false)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "🔇 Adventure news emission is now OFF. No new dispatches will be sent.")
|
||||
case "", "status", "help":
|
||||
named := "using your character name"
|
||||
if isNewsOptedOut(ctx.Sender) {
|
||||
named = "anonymized (\"an adventurer\")"
|
||||
}
|
||||
msg := "📰 **Pete's Adventure News** reports realm happenings — deaths, first-clears, arrivals, duels.\n" +
|
||||
"You are currently " + named + ".\n" +
|
||||
"`!news optout` to be anonymized · `!news optin` to be named again."
|
||||
if p.IsAdmin(ctx.Sender) {
|
||||
state := "ON"
|
||||
if !newsEmissionOn() {
|
||||
state = "OFF"
|
||||
}
|
||||
msg += "\n_(admin)_ emission is **" + state + "** · `!news on` / `!news off` to toggle."
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown option. Try `!news`, `!news optout`, or `!news optin`.")
|
||||
}
|
||||
}
|
||||
|
||||
// nowUnix is the event timestamp helper (kept in one place so the emit sites
|
||||
// read uniformly).
|
||||
func nowUnix() int64 { return time.Now().Unix() }
|
||||
|
||||
// claimRealmFirst records the first occurrence of a (kind,target) across the
|
||||
// realm and reports whether THIS call was the first — true exactly once. Used to
|
||||
// tier realm-firsts (PRIORITY) apart from repeat clears (BULLETIN). The backfill
|
||||
// pre-seeds this ledger so historical firsts aren't re-announced after a deploy.
|
||||
func claimRealmFirst(kind, target string) bool {
|
||||
res := db.ExecResult("news realm-first claim",
|
||||
`INSERT OR IGNORE INTO news_realm_firsts (kind, target, first_at) VALUES (?, ?, unixepoch())`,
|
||||
kind, target)
|
||||
if res == nil {
|
||||
return false
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The
|
||||
// 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
|
||||
}
|
||||
// Claim the realm-first BEFORE the name guard, so an unnamed straggler's
|
||||
// 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 := "zone_clear"
|
||||
if claimRealmFirst("zone", string(exp.ZoneID)) {
|
||||
eventType = "zone_first"
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
region := ""
|
||||
if IsMultiRegionZone(exp.ZoneID) {
|
||||
if r, ok := CurrentRegion(exp); ok {
|
||||
region = r.Name
|
||||
}
|
||||
}
|
||||
lvl := charLevel(userID)
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("%s:%d", exp.ZoneID, ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("%s:%s:%s:%d", eventType, eventToken(userID, disc), exp.ZoneID, ts),
|
||||
EventType: eventType,
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Region: region,
|
||||
Boss: zone.Boss.Name,
|
||||
Level: lvl,
|
||||
Outcome: "cleared",
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
162
internal/plugin/pete_retreat_test.go
Normal file
162
internal/plugin/pete_retreat_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The retreat bulletin: the news can finally say "it went badly and nobody
|
||||
// died". Before this, every event type Pete could file was a win, a death, or an
|
||||
// introduction — so a run that simply fell apart was reported as nothing at all,
|
||||
// and a class that retreated a third of the time just looked absent from the
|
||||
// feed.
|
||||
//
|
||||
// The reason gate is the whole safety property. A death must not ALSO be filed
|
||||
// as a retreat (it already files its own priority dispatch), and an idle reap
|
||||
// must not be filed at all — Pete telling the realm that a named player was
|
||||
// driven from the field, when in truth they closed their laptop, is a lie about
|
||||
// a person by name.
|
||||
|
||||
func retreatFactFor(t *testing.T, uid id.UserID) map[string]any {
|
||||
t.Helper()
|
||||
var payload string
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT payload FROM pete_emit_queue WHERE guid LIKE 'retreat:%'`).Scan(&payload)
|
||||
if err != nil {
|
||||
t.Fatalf("no retreat fact queued: %v", err)
|
||||
}
|
||||
var f map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestRetreatNews_ReasonGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
reason string
|
||||
want int
|
||||
why string
|
||||
}{
|
||||
{lossCombatRetreat, 1, "a solo walker who ran out the clock and withdrew is news"},
|
||||
{lossCombatFlee, 1, "a party that broke off is news"},
|
||||
{lossCombatDeath, 0, "a death already files its own priority dispatch — do not double-report it as a retreat"},
|
||||
{lossIdleTimeout, 0, "an idle reap is not a retreat; the player closed their laptop, they did not flee"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.reason, func(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
enablePeteSeam(t)
|
||||
uid := id.UserID("@retreat:example.org")
|
||||
zoneCmdTestCharacter(t, uid, 10)
|
||||
|
||||
emitRetreatNews(uid, tc.reason, ZoneID("underforge"), 3)
|
||||
|
||||
if got := queuedCount(t, "retreat:%"); got != tc.want {
|
||||
t.Fatalf("reason %q queued %d retreat facts, want %d — %s",
|
||||
tc.reason, got, tc.want, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The bulletin has to carry enough for Pete to write a sentence: who, where, how
|
||||
// far they got. A retreat with no day count is just "someone left".
|
||||
func TestRetreatNews_CarriesTheStory(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
enablePeteSeam(t)
|
||||
uid := id.UserID("@retreat-story:example.org")
|
||||
zoneCmdTestCharacter(t, uid, 10)
|
||||
|
||||
emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 3)
|
||||
|
||||
f := retreatFactFor(t, uid)
|
||||
if f["event_type"] != "retreat" {
|
||||
t.Errorf("event_type = %v, want retreat", f["event_type"])
|
||||
}
|
||||
// Bulletin, not priority: a retreat is a bad day, not a funeral.
|
||||
if f["tier"] != "bulletin" {
|
||||
t.Errorf("tier = %v, want bulletin", f["tier"])
|
||||
}
|
||||
if f["outcome"] != "retreated" {
|
||||
t.Errorf("outcome = %v, want retreated", f["outcome"])
|
||||
}
|
||||
if f["count"] != float64(3) {
|
||||
t.Errorf("count = %v, want 3 (the day it fell apart)", f["count"])
|
||||
}
|
||||
if f["zone"] == nil || f["zone"] == "" {
|
||||
t.Error("no zone — Pete cannot say where it happened")
|
||||
}
|
||||
if f["subject"] == nil || f["subject"] == "" {
|
||||
t.Error("no subject — Pete cannot say who it happened to")
|
||||
}
|
||||
// GUID prefix must equal the event type; Pete's taxonomy keys off it.
|
||||
if guid, _ := f["guid"].(string); len(guid) < 8 || guid[:8] != "retreat:" {
|
||||
t.Errorf("guid %q must be prefixed with the event type", guid)
|
||||
}
|
||||
}
|
||||
|
||||
// The wiring, not just the emitter. Everything above calls emitRetreatNews
|
||||
// directly, which proves nothing about the game: the fact only ships if the real
|
||||
// run-loss chokepoint actually calls it, on a real expedition, and reads the day
|
||||
// count BEFORE forcedExtractExpedition stamps the row 'abandoned' and takes the
|
||||
// live fields with it. Drive the chokepoint.
|
||||
func TestRetreatNews_WiredToTheRunLossChokepoint(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
enablePeteSeam(t)
|
||||
uid := id.UserID("@retreat-wired:example")
|
||||
campTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
forceExtractExpeditionForRunLoss(uid, lossCombatRetreat)
|
||||
|
||||
if got := queuedCount(t, "retreat:%"); got != 1 {
|
||||
t.Fatalf("the run-loss chokepoint queued %d retreat facts, want 1 — "+
|
||||
"emitRetreatNews passes its own unit tests but is not actually wired to the game", got)
|
||||
}
|
||||
f := retreatFactFor(t, uid)
|
||||
// The day must survive the extract. Read it after forcedExtractExpedition and
|
||||
// it is gone — the row is 'abandoned' and the live fields are zeroed.
|
||||
if f["count"] != float64(exp.CurrentDay) {
|
||||
t.Errorf("count = %v, want %d — the day count was read after the extract wiped it",
|
||||
f["count"], exp.CurrentDay)
|
||||
}
|
||||
// And the expedition really did end; a bulletin about a run still in progress
|
||||
// would be worse than no bulletin.
|
||||
if still, _ := getActiveExpedition(uid); still != nil {
|
||||
t.Error("expedition still active after a run-loss extract")
|
||||
}
|
||||
}
|
||||
|
||||
// The opt-out covers the new event type too. A player who asked not to be named
|
||||
// must not be named when they lose — that is precisely when they'd mind most.
|
||||
func TestRetreatNews_HonoursOptOut(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
enablePeteSeam(t)
|
||||
uid := id.UserID("@retreat-shy:example.org")
|
||||
zoneCmdTestCharacter(t, uid, 10)
|
||||
setNewsOptout(uid, true)
|
||||
|
||||
emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 2)
|
||||
|
||||
f := retreatFactFor(t, uid)
|
||||
if f["subject"] != anonName {
|
||||
t.Fatalf("subject = %v, want %q — the opt-out must cover retreats", f["subject"], anonName)
|
||||
}
|
||||
actors, _ := f["actors"].([]any)
|
||||
for _, a := range actors {
|
||||
if a != anonName {
|
||||
t.Fatalf("actors leaked %v past the opt-out", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
216
internal/plugin/pete_test.go
Normal file
216
internal/plugin/pete_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// enablePeteSeam turns on the emit seam for a test and restores it to disabled
|
||||
// afterwards, so a leaked-enabled singleton can't affect other tests.
|
||||
func enablePeteSeam(t *testing.T) {
|
||||
t.Helper()
|
||||
os.Setenv("FEATURE_PETE_NEWS", "true")
|
||||
os.Setenv("PETE_INGEST_URL", "http://127.0.0.1:0")
|
||||
os.Setenv("PETE_INGEST_TOKEN", "tok")
|
||||
peteclient.Init()
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("FEATURE_PETE_NEWS")
|
||||
os.Unsetenv("PETE_INGEST_URL")
|
||||
os.Unsetenv("PETE_INGEST_TOKEN")
|
||||
peteclient.Init()
|
||||
})
|
||||
}
|
||||
|
||||
func queuedCount(t *testing.T, likeGUID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM pete_emit_queue WHERE guid LIKE ?`, likeGUID).Scan(&n); err != nil {
|
||||
t.Fatalf("queue count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestNewsOptoutRoundTrip: opting out records the player, opting in clears them,
|
||||
// both idempotently.
|
||||
func TestNewsOptoutRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
u := id.UserID("@zapp:example.org")
|
||||
if isNewsOptedOut(u) {
|
||||
t.Fatal("fresh player should not be opted out")
|
||||
}
|
||||
|
||||
setNewsOptout(u, true)
|
||||
if !isNewsOptedOut(u) {
|
||||
t.Error("opt-out not recorded")
|
||||
}
|
||||
setNewsOptout(u, true) // idempotent
|
||||
if !isNewsOptedOut(u) {
|
||||
t.Error("second opt-out flipped state")
|
||||
}
|
||||
|
||||
setNewsOptout(u, false)
|
||||
if isNewsOptedOut(u) {
|
||||
t.Error("opt-in did not clear")
|
||||
}
|
||||
setNewsOptout(u, false) // idempotent
|
||||
if isNewsOptedOut(u) {
|
||||
t.Error("second opt-in flipped state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeteNewsBackfill: the one-shot replays deaths + solo achievements + zone
|
||||
// realm-firsts through the emit queue, seeds the realm-first ledger, and is
|
||||
// idempotent on a second boot.
|
||||
func TestPeteNewsBackfill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
enablePeteSeam(t)
|
||||
|
||||
// A named, dead adventurer.
|
||||
db.Exec("seed death", `INSERT INTO player_meta (user_id, display_name, last_death_date, death_location)
|
||||
VALUES (?, ?, ?, ?)`, "@zapp:x", "Zapp", "2026-06-01", "the Underforge")
|
||||
// A single-holder achievement.
|
||||
db.Exec("seed achv", `INSERT INTO achievements (user_id, achievement_id, unlocked_at)
|
||||
VALUES (?, ?, ?)`, "@zapp:x", "adv_first_blood", 1717200000)
|
||||
// A completed, boss-defeated zone run → realm-first.
|
||||
db.Exec("seed run", `INSERT INTO dnd_zone_run (run_id, user_id, zone_id, total_rooms, boss_defeated, completed_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`, "r1", "@zapp:x", "goblin_warren", 5, "2026-05-20 12:00:00")
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
|
||||
if got := queuedCount(t, "death:%"); got != 1 {
|
||||
t.Errorf("death dispatches queued = %d, want 1", got)
|
||||
}
|
||||
if got := queuedCount(t, "milestone:%"); got != 1 {
|
||||
t.Errorf("achievement dispatches queued = %d, want 1", got)
|
||||
}
|
||||
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
||||
t.Errorf("zone-first dispatches queued = %d, want 1", got)
|
||||
}
|
||||
// Realm-first ledger seeded so a later live clear tiers as a repeat.
|
||||
if claimRealmFirst("zone", "goblin_warren") {
|
||||
t.Error("realm-first ledger not seeded — a live clear would mis-announce as first-ever")
|
||||
}
|
||||
|
||||
// Second boot: job gate holds, nothing re-queued.
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
if got := queuedCount(t, "%"); got != 3 {
|
||||
t.Errorf("total queued after re-run = %d, want 3 (idempotent)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventToken: the public GUID token is salted (not a bare sha256 an attacker
|
||||
// can recompute from the handle), stable per logical event (idempotency), and
|
||||
// per-event so a user's tokens don't link to each other.
|
||||
func TestEventToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
// The salt is cached process-wide via sync.Once; reset it so it derives (and
|
||||
// persists) against this test's fresh DB rather than an earlier test's.
|
||||
guidSaltOnce = sync.Once{}
|
||||
guidSalt = nil
|
||||
|
||||
u := id.UserID("@alice:example.org")
|
||||
|
||||
// Not the old unsalted digest: an attacker with the handle can't recompute it.
|
||||
bare := sha256.Sum256([]byte(u))
|
||||
if got := eventToken(u, "arrival"); got == hex.EncodeToString(bare[:9]) {
|
||||
t.Error("token matches unsalted sha256(handle) — enumeration attack reopened")
|
||||
}
|
||||
|
||||
// Stable for the same (user, discriminator): a re-emit dedups.
|
||||
if eventToken(u, "arrival") != eventToken(u, "arrival") {
|
||||
t.Error("token not stable for the same event — idempotency broken")
|
||||
}
|
||||
|
||||
// Per-event: two different events for the same user yield unrelated tokens, so
|
||||
// a named event can't be walked back to the user's anonymized events.
|
||||
if eventToken(u, "arrival") == eventToken(u, "death:123") {
|
||||
t.Error("tokens collide across events — linkage attack reopened")
|
||||
}
|
||||
|
||||
// Different users, same discriminator, still differ.
|
||||
if eventToken(u, "arrival") == eventToken(id.UserID("@bob:example.org"), "arrival") {
|
||||
t.Error("token collides across users")
|
||||
}
|
||||
|
||||
// The salt persisted, so it survives a restart (a re-emit after reboot dedups).
|
||||
var salt string
|
||||
if err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt); err != nil || salt == "" {
|
||||
t.Fatalf("guid salt not persisted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitZoneClearTaxonomy: the realm's first clear of a zone emits a PRIORITY
|
||||
// zone_first; a later clear emits a BULLETIN zone_clear — distinct event_type and
|
||||
// GUID prefix, so Pete never files a repeat as a first-ever.
|
||||
func TestEmitZoneClearTaxonomy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
enablePeteSeam(t)
|
||||
|
||||
db.Exec("seed player", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`,
|
||||
"@zapp:x", "Zapp")
|
||||
exp := &Expedition{ZoneID: "goblin_warren"}
|
||||
|
||||
emitZoneClearNews(id.UserID("@zapp:x"), exp) // realm-first
|
||||
emitZoneClearNews(id.UserID("@zapp:x"), exp) // repeat
|
||||
|
||||
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
||||
t.Errorf("zone_first queued = %d, want 1 (the realm-first)", got)
|
||||
}
|
||||
if got := queuedCount(t, "zone_clear:%"); got != 1 {
|
||||
t.Errorf("zone_clear queued = %d, want 1 (the repeat)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
|
||||
func TestNewsEmissionKillSwitch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
if !newsEmissionOn() {
|
||||
t.Error("emission should default ON")
|
||||
}
|
||||
setNewsEmission(false)
|
||||
if newsEmissionOn() {
|
||||
t.Error("emission still ON after disable")
|
||||
}
|
||||
setNewsEmission(true)
|
||||
if !newsEmissionOn() {
|
||||
t.Error("emission still OFF after enable")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user