mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
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
457 lines
17 KiB
Go
457 lines
17 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)
|
||
}
|
||
|
||
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
|
||
}
|