mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
The web's own refusals named commands it had no button for: "!expedition abandon first", "!expedition leave to walk out alone", "no refund if you cancel early". Three verbs, no arguments, no money. Each Matrix handler becomes framing over a headless twin the order path shares, so the web runs the game's real path: an abandon still disbands the roster, still retires the region runs, still DMs the party, still rolls the emergence pet. The lock asymmetry is per verb and is the thing to be careful about. performExpeditionAbandon and performExpeditionLeave cannot take advUserLock — their Matrix caller holds it across the whole !expedition switch — so their web wrappers do. performBabysitCancel takes it itself, because its caller does not, so its wrapper must not. Both directions are pinned by tests that hang rather than fail on regression. Also flips the two W5a extract tests off setupZoneRunTestDB: it copies data/gogobee.db and skips when it is missing, so they have been green-by-skipping since they were written.
489 lines
18 KiB
Go
489 lines
18 KiB
Go
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)
|
||
}
|
||
|
||
// 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
|
||
}
|