Files
gogobee/internal/plugin/adventure_babysit.go
prosolis 509df7fadf adventure: stop the log claiming a win it didn't see
Four things the review found, all of them the code telling a player
something that isn't true.

A run that walks into a dead end filed its ending as "cleared" whatever
the caller said, so both the liveblog and the summary reported a clear
for a party that merely ran out of map — and the end beat is
first-writer-wins, so nothing later could take it back. It says
"cleared" only for a boss now.

The re-offer branches of babysit and resume returned a zero cost, so a
player who was in fact charged read "0 coins" in the verdict. Both
re-quote the price they actually took.

And the realm-firsts reseed retired its one-shot job even when the read
under it had failed, which on a transient fault at Init would have left
the ledger mis-dated permanently. The read now says whether it worked,
and the job stays open when it didn't.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 22:07:38 -07:00

496 lines
18 KiB
Go
Raw Permalink 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) {
// Re-quote the fee rather than leaving it zero: the verdict this
// feeds prints the coin figure, and "0 coins" would be a false
// receipt for a hire the player did pay for.
return babysitOutcome{
Days: days,
Cost: babysitDailyCost(dndLevelForUser(char.UserID)) * 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)
}
// babysitCancelOutcome is what dismissing the sitter did. Summary is the Matrix
// block of what they got through while they were here — a paragraph of counts,
// which is right under a DM and too much for a one-line web verdict, so the
// caller decides whether to print it.
type babysitCancelOutcome struct {
Summary string
PetName string
}
// errBabysitNoSitter is the one way cancelling can be refused. There is no
// "already cancelled" race to worry about: the check and the write are both under
// the per-user lock this takes.
var errBabysitNoSitter = errors.New("babysit: no sitter to dismiss")
// performBabysitCancel is `!adventure babysit cancel` minus the command framing.
// Shared with the web action queue.
//
// This one TAKES the per-user lock, unlike the abandon/leave twins beside it in
// the order path — because its Matrix caller does not hold it (handleBabysitCmd
// dispatches straight here, where `!expedition` holds the lock across its whole
// switch). Its web wrapper must therefore NOT take it. The asymmetry is per verb
// and is worth checking against the Matrix caller every time one is added.
//
// No refund, by design: the sitter was already here.
func (p *AdventurePlugin) performBabysitCancel(uid id.UserID) (babysitCancelOutcome, error) {
userMu := p.advUserLock(uid)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(uid)
if err != nil {
return babysitCancelOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found.")
}
if !char.BabysitActive {
return babysitCancelOutcome{}, refuseAdv(errBabysitNoSitter, "🍼 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)
}
out := babysitCancelOutcome{Summary: renderBabysitSummary(char, logs), PetName: char.PetName}
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 out, nil
}
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
out, err := p.performBabysitCancel(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, err.Error())
}
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+out.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
}