Files
gogobee/internal/plugin/adventure_babysit.go
T
prosolis f73ab56ac8 adventure: run the web's three new verbs through the game's own paths
The game-side half of setting out, going back in, and hiring the sitter from
the web. Each is the existing command minus its framing: performExpeditionStart,
performResume and performBabysitPurchase now hold the guards and the money, and
!expedition start, !resume and !adventure babysit are what is left over. So a
departure booked from a phone is the same departure - same eligibility chain,
same supply freebies, same opening log line - rather than a second one that
drifts.

Refusals travel as advRefusal, which wraps a sentinel AND carries the finished
sentence. That is what lets the commands keep the exact copy they always sent
while the web gets a machine-readable verdict.

All three spend coins on a retrying wire, so the debit is keyed to the order
guid and a re-offer cannot charge twice. The subtle half is what a re-offer
should ANSWER: a settled debit plus an already-started expedition means the
order worked and lost its ack, not that the player is busy, so it reports
applied instead of refusing the thing it did. Nothing refunds-then-retries -
after a refund the keyed debit will not charge again, so a retry would hand over
the goods for free, and every failure past the debit is therefore permanent.

Also fixes a deadlock that predates all of this: !expedition extract and
!expedition resume are aliases for two commands that take the per-user lock
themselves, and the alias dispatcher already held it. Since it is a plain
sync.Mutex the handler blocked forever and, because the deferred unlock never
ran, every later adventure command from that player wedged too. It does not
fail loudly on regression - it hangs - so the new test asserts with a timeout.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 19:47:45 -07:00

457 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Pricing ─────────────────────────────────────────────────────────────────
// babysitDailyCost returns the daily babysit subscription cost in €.
// Phase L (post-L5g): keyed off D&D Level instead of legacy CombatLevel.
// The slope is 5× the old per-level slope to preserve the curve shape across
// the 5:1 compression in dndLevelFromCombatLevel — Level 4 (~old CL 20) =
// €500/day, Level 10 (~old CL 50) = €1100/day, matching pre-migration pricing.
func babysitDailyCost(level int) int {
return 100 + (level * 100)
}
// ── Pet-care daily trickle ─────────────────────────────────────────────────
// petXPPerBabysitDay is the daily pet XP awarded while a babysit subscription
// is active. Picked to be a meaningful but not overwhelming push toward L10:
// roughly equivalent to a couple of player-driven actions per day.
const petXPPerBabysitDay = 3
// runBabysitDailyTrickle grants daily pet XP while a babysit subscription is
// active and logs an entry for the end-of-service summary. Caller is
// responsible for saving the character afterwards.
func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
if !char.BabysitActive {
return
}
// Both companions share the sitter's attention and gain the flat trickle.
// (Combat only ever reads their *averaged* procs, so leveling both is not a
// power spike.)
leveled := false
if char.HasPet() {
leveled = advancePetLevelsFromXP(&char.PetXP, &char.PetLevel, &char.PetLevel10Date, petXPPerBabysitDay*100) || leveled
}
if char.HasPet2() {
leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled
}
outcome := "pet_care"
if leveled {
outcome = "pet_care_levelup"
}
logBabysitActivity(char.UserID, "pet_care", outcome, 0, petXPPerBabysitDay, "")
}
// BabysitSafeRest reports whether the user has an active babysit subscription
// that should let standard camps qualify for fortified-tier rest perks.
// Returns false on any error (treat as no babysit). Safe to call from tests
// where the global DB has not been initialized — the panic is swallowed.
func BabysitSafeRest(userID id.UserID) (active bool) {
defer func() {
if r := recover(); r != nil {
active = false
}
}()
char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.BabysitActive {
return false
}
if char.BabysitExpiresAt != nil && time.Now().UTC().After(*char.BabysitExpiresAt) {
return false
}
return true
}
// ── Command Handlers ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) error {
lower := strings.ToLower(strings.TrimSpace(args))
switch {
case lower == "status":
return p.handleBabysitStatus(ctx)
case lower == "cancel":
return p.handleBabysitCancel(ctx)
case lower == "week":
return p.handleBabysitPurchase(ctx, 7)
case lower == "month":
return p.handleBabysitPurchase(ctx, 30)
default:
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
" • Standard camps act like fortified ones — rest deeply, no need to have downed the zone boss\n"+
" • Rival duels declined on your behalf\n\n"+
"`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+
"`!adventure babysit status` — check service status\n"+
"`!adventure babysit cancel` — cancel early (no refund)")
}
}
// Sentinels for the ways hiring a sitter can be refused, so the web action queue
// (pete_orders.go) can pick a verdict without parsing prose. Each is returned
// inside an advRefusal carrying the finished sentence, so `!adventure babysit`
// keeps the copy it always sent.
var (
errBabysitNoCharacter = errors.New("babysit: no adventurer")
errBabysitActive = errors.New("babysit: a sitter is already engaged")
errBabysitDead = errors.New("babysit: adventurer is dead")
errBabysitBroke = errors.New("babysit: cannot cover the fee")
errBabysitFailed = errors.New("babysit: could not engage a sitter")
)
// babysitOutcome is what hiring did, for a caller describing it somewhere other
// than a DM.
type babysitOutcome struct {
Days int
Cost int
PetName string
PetLine string
Confirm string
}
// performBabysitPurchase is `!adventure babysit week|month` minus the command
// framing. Shared with the web action queue so hiring a sitter from a phone
// engages the same one, on the same clock, with the same log reset.
//
// idemKey, when set, is the web order's guid and moves the fee onto DebitIdem so
// a re-offered order cannot charge twice.
func (p *AdventurePlugin) performBabysitPurchase(uid id.UserID, days int, idemKey string) (babysitOutcome, error) {
userMu := p.advUserLock(uid)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(uid)
if err != nil {
return babysitOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found. Type `!adventure` to create one.")
}
if char.BabysitActive {
// A web order that already paid and already engaged the sitter lands here
// on the re-offer. The settled fee is what tells that apart from somebody
// who really does already have one.
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
return babysitOutcome{Days: days, PetName: char.PetName}, nil
}
return babysitOutcome{}, refuseAdv(errBabysitActive, "🍼 The babysitter is already here. They're not leaving until the job is done.")
}
if !char.Alive {
return babysitOutcome{}, refuseAdv(errBabysitDead, "Your adventurer is dead. The babysitter does not work with corpses.")
}
daily := babysitDailyCost(dndLevelForUser(char.UserID))
totalCost := daily * days
if p.euro == nil {
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Coin system unavailable — try again later.")
}
// Skip the affordability gate on a re-offer that already paid: the fee is a
// settled fact, and re-reading the now-lower balance would bounce a sitter the
// player has bought.
if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) {
balance := p.euro.GetBalance(char.UserID)
if balance < float64(totalCost) {
return babysitOutcome{}, refuseAdv(errBabysitBroke,
"🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.",
fmtEuro(totalCost), days, fmtEuro(balance))
}
}
debited := false
if idemKey != "" {
ok, _, err := p.euro.DebitIdem(char.UserID, float64(totalCost), "babysit_purchase", idemKey)
debited = err == nil && ok
} else {
debited = p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase")
}
if !debited {
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Payment failed. The babysitter looked at your wallet and walked away.")
}
clearBabysitLogs(char.UserID)
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
char.BabysitActive = true
char.BabysitExpiresAt = &expires
char.BabysitSkillFocus = "" // legacy field; no longer used
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
if idemKey != "" {
if _, _, err := p.euro.CreditIdem(char.UserID, float64(totalCost), "babysit_refund", idemKey+":refund"); err != nil {
slog.Error("babysit: refund failed", "user", char.UserID, "order", idemKey, "err", err)
}
} else {
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
}
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Something went wrong activating the service. Your gold has been refunded.")
}
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
}
petLine := "No pet to tend yet — the babysitter will keep that in mind."
if char.HasPet() {
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
}
return babysitOutcome{
Days: days, Cost: totalCost, PetName: char.PetName, PetLine: petLine,
Confirm: pickBabysitFlavor(babysitConfirmLines),
}, nil
}
// handleBabysitPurchase is the command framing around performBabysitPurchase.
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
out, err := p.performBabysitPurchase(ctx.Sender, days, "")
if err != nil {
return p.SendDM(ctx.Sender, err.Error())
}
durLabel := "1 week"
if days == 30 {
durLabel = "1 month"
}
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
"Duration: %s (%d days)\n"+
"Cost: €%d\n"+
"%s\n"+
"Camp safety: standard camps now rest like fortified ones\n"+
"Rival duels: declined on your behalf\n\n"+
"_%s_", durLabel, days, out.Cost, out.PetLine, out.Confirm)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 No active babysitting service.\n\nUse `!adventure babysit week` or `!adventure babysit month` to start. The babysitter tends your pet daily and lets you rest deeply at standard camps.")
}
remaining := "unknown"
if char.BabysitExpiresAt != nil {
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
if days < 1 {
remaining = "less than a day"
} else {
remaining = fmt.Sprintf("%d days", days)
}
}
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
totalXP, petDays, rivalsRefused := babysitLogStats(logs)
petLine := "No pet to tend"
if char.HasPet() {
petLine = fmt.Sprintf("%s (L%d)", char.PetName, char.PetLevel)
}
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
"Time remaining: %s\n"+
"Pet under care: %s\n"+
"Days of pet care given: %d\n"+
"Pet XP trickled: %d\n"+
"Rivals declined: %d",
remaining, petLine, petDays, totalXP, rivalsRefused)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
}
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(char, logs)
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err)
}
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err)
}
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
}
// ── Expiry Check ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
now := time.Now().UTC()
for _, char := range chars {
if !char.BabysitActive {
continue
}
if char.BabysitExpiresAt == nil || now.Before(*char.BabysitExpiresAt) {
continue
}
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(&char, logs)
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(&char); err != nil {
slog.Error("babysit: failed to save character on expiry", "user", char.UserID, "err", err)
continue
}
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(&char)); err != nil {
slog.Error("player_meta: babysit expiry dual-write failed", "user", char.UserID, "err", err)
}
if err := p.SendDM(char.UserID, summary); err != nil {
slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err)
}
}
}
// ── Summary Rendering ───────────────────────────────────────────────────────
func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string {
totalXP, petDays, rivalsRefused := babysitLogStats(logs)
var sb strings.Builder
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
sb.WriteString(fmt.Sprintf("Days of service: %d\n", petDays))
if char.HasPet() {
sb.WriteString(fmt.Sprintf("Pet looked after: %s (L%d)\n", char.PetName, char.PetLevel))
} else {
sb.WriteString("Pet looked after: none — the babysitter played solitaire.\n")
}
sb.WriteString(fmt.Sprintf("Pet XP trickled: %d\n", totalXP))
if rivalsRefused > 0 {
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
for _, log := range logs {
if log.RivalRefused != "" {
line := pickBabysitFlavor(babysitRivalRefusalLines)
sb.WriteString(fmt.Sprintf(" %s\n", fmt.Sprintf(line, log.RivalRefused, log.LogDate)))
}
}
}
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
sb.WriteString("\n\nYour adventurer is fed and rested. The pet is suspiciously well-trained.")
return sb.String()
}
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
type babysitLogEntry struct {
ID int64
UserID id.UserID
LogDate string
Activity string
Outcome string
GoldEarned int
XPGained int
ItemsDropped string
RivalRefused string
}
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {
d := db.Get()
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, gold_earned, xp_gained, items_dropped)
VALUES (?, DATE('now'), ?, ?, ?, ?, ?)`,
string(userID), activity, outcome, gold, xp, items)
if err != nil {
slog.Error("babysit: failed to log activity", "user", userID, "err", err)
}
}
func logBabysitRivalRefusal(userID id.UserID, rivalName string) {
d := db.Get()
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, rival_refused)
VALUES (?, DATE('now'), 'rival_refused', 'declined', ?)`,
string(userID), rivalName)
if err != nil {
slog.Error("babysit: failed to log rival refusal", "user", userID, "err", err)
}
}
func loadBabysitLogs(userID id.UserID) ([]babysitLogEntry, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, user_id, log_date, activity, outcome, gold_earned, xp_gained,
COALESCE(items_dropped,''), COALESCE(rival_refused,'')
FROM adventure_babysit_log WHERE user_id = ? ORDER BY log_date`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var logs []babysitLogEntry
for rows.Next() {
var l babysitLogEntry
if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.Activity, &l.Outcome,
&l.GoldEarned, &l.XPGained, &l.ItemsDropped, &l.RivalRefused); err != nil {
return nil, err
}
logs = append(logs, l)
}
return logs, nil
}
func clearBabysitLogs(userID id.UserID) {
d := db.Get()
if _, err := d.Exec(`DELETE FROM adventure_babysit_log WHERE user_id = ?`, string(userID)); err != nil {
slog.Error("babysit: failed to clear logs", "user", userID, "err", err)
}
}
// ── Stats helper ────────────────────────────────────────────────────────────
func babysitLogStats(logs []babysitLogEntry) (totalXP, petDays, rivalsRefused int) {
for _, l := range logs {
totalXP += l.XPGained
if l.Activity == "pet_care" {
petDays++
}
if l.RivalRefused != "" {
rivalsRefused++
}
}
return
}