package plugin import ( "fmt" "log/slog" "math" "math/rand/v2" "strings" "time" "maunium.net/go/mautrix/id" ) // ── Pending Interaction Types ────────────────────────────────────────────── type advPendingHospitalPay struct { Cost int64 // after-insurance amount (recomputed at confirm time for TOCTOU safety) Discounted bool // pinned haggle outcome — when true, recomputed base is halved Waived bool // pinned nat-20 outcome — full waiver, no debit } // hospitalCostsForUser returns (beforeInsurance, afterInsurance) for the // hospital revival bill. `Level × 5_000` after insurance, 5× that before. // Floors at level 1. func hospitalCostsForUser(char *AdventureCharacter) (int64, int64) { level := 1 if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 { level = dnd.Level } after := int64(level) * 5_000 before := after * 5 return before, after } // hospitalHaggleDC is the target for the CHA-based haggle check. Pass = 50% off. const hospitalHaggleDC = 15 type hospitalHaggleOutcome int const ( haggleFumble hospitalHaggleOutcome = iota // nat 1 — auto-fail, full price haggleFail // d20+CHA < 15 haggleWin // d20+CHA ≥ 15 haggleCrit // nat 20 — bill waived ) // rollHospitalHaggle rolls d20 + CHA modifier vs DC 15. Returns the raw roll, // the CHA modifier, and the outcome. Nat 1 is an auto-fumble (full price); // nat 20 is an auto-crit (full waiver) regardless of modifier. // Falls back to 0 mod if the DnD row can't be loaded. func rollHospitalHaggle(userID id.UserID) (int, int, hospitalHaggleOutcome) { mod := 0 if dnd, err := LoadDnDCharacter(userID); err == nil && dnd != nil { mod = abilityModifier(dnd.CHA) } r := rand.IntN(20) + 1 switch { case r == 20: return r, mod, haggleCrit case r == 1: return r, mod, haggleFumble case r+mod >= hospitalHaggleDC: return r, mod, haggleWin default: return r, mod, haggleFail } } // ── Hospital Command ─────────────────────────────────────────────────────── func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { char, err := loadAdvCharacter(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "You're not registered for Adventure yet. Type `!adventure` to begin.") } // If alive, reject if char.Alive { text, _ := advPickFlavor(nurseJoyAlive, ctx.Sender, "hospital_alive") return p.SendDM(ctx.Sender, text) } // On-demand revive if death timer already expired if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) { char.Alive = true char.DeadUntil = nil if err := saveAdvCharacter(char); err != nil { slog.Error("hospital: on-demand revive failed", "user", char.UserID, "err", err) return p.SendDM(ctx.Sender, "Something went wrong at the hospital. Try again in a moment.") } if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil { dnd.HPCurrent = dnd.HPMax if err := SaveDnDCharacter(dnd); err != nil { slog.Error("hospital: failed to restore DnDCharacter HP on natural revive", "user", char.UserID, "err", err) } } return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) } // Compute costs (level × 5k after insurance, 5× before) beforeInsurance, baseAfterInsurance := hospitalCostsForUser(char) // Haggle: CHA save vs DC 15. Nat 20 waives the bill, nat 1 is auto-full, // otherwise pass = half off. haggleRoll, haggleMod, haggleOutcome := rollHospitalHaggle(ctx.Sender) afterInsurance := baseAfterInsurance switch haggleOutcome { case haggleCrit: afterInsurance = 0 case haggleWin: afterInsurance /= 2 } // Check balance balance := p.euro.GetBalance(ctx.Sender) if balance < float64(afterInsurance) { // Can't afford — back to the ditch text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke") p.SendDM(ctx.Sender, text) // Room announcement gr := gamesRoom() if gr != "" { name, _ := loadDisplayName(char.UserID) p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name)) } return nil } // Check if holiday for bill surcharge isHol, _ := isHolidayToday() // Build the full multi-act DM var sb strings.Builder // Phase L4a: HospitalVisits sourced from player_meta (with AdvCharacter // fallback during soak). visits, _ := loadHospitalVisits(char.UserID) // Act 1 — Admission if visits == 0 { sb.WriteString(nurseJoyFirstVisit) } admission, _ := advPickFlavor(nurseJoyAdmission, ctx.Sender, "hospital_admit") sb.WriteString(admission) sb.WriteString("\n\n") // Act 2 — The Bill (always shows the un-haggled after-insurance figure; // the haggle adjustment is announced separately below). sb.WriteString(generateItemizedBill(beforeInsurance, baseAfterInsurance, visits, isHol)) sb.WriteString("\n\n") delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery") sb.WriteString(delivery) sb.WriteString("\n\n───\n\n") // Act 3 — After Insurance (pre-haggle figure) afterText, _ := advPickFlavor(nurseJoyAfterInsurance, ctx.Sender, "hospital_after") // Some entries have two %d placeholders (she says the number twice) count := strings.Count(afterText, "%d") switch count { case 1: afterText = fmt.Sprintf(afterText, baseAfterInsurance) case 2: afterText = fmt.Sprintf(afterText, baseAfterInsurance, baseAfterInsurance) } sb.WriteString(afterText) sb.WriteString("\n\n───\n\n") // Act 4 — The Haggle. Nurse Joy lets you make a charisma save on your bill. total := haggleRoll + haggleMod flavor, _ := advPickFlavor(nurseJoyHaggleFlavor[haggleOutcome], ctx.Sender, fmt.Sprintf("hospital_haggle_%d", haggleOutcome)) var verdict string switch haggleOutcome { case haggleCrit: verdict = "🎉 **NAT 20 — bill waived!**" case haggleWin: verdict = "✅ *Passed!* The bill is halved." case haggleFumble: verdict = "💀 **NAT 1 — auto-fail.** Full price stands." default: verdict = "❌ *Failed.* Full price stands." } sb.WriteString(fmt.Sprintf( "🎲 **Haggle Check (CHA DC %d)**\n"+ "You attempt to charm Nurse Joy into a discount...\n"+ "d20 = **%d**, CHA mod %+d → **total %d** vs DC %d\n"+ "%s\n\n%s\n\n───\n\n", hospitalHaggleDC, haggleRoll, haggleMod, total, hospitalHaggleDC, verdict, flavor)) // Payment prompt — show base → final adjustment when it changed. sb.WriteString("**St. Guildmore's Memorial Hospital**\n") switch haggleOutcome { case haggleCrit: sb.WriteString(fmt.Sprintf("Original: ~~%s~~ → **WAIVED**\n", fmtEuro(baseAfterInsurance))) sb.WriteString("Amount due: **€0**\n\n") case haggleWin: sb.WriteString(fmt.Sprintf("Original: ~~%s~~ → **%s** (haggled)\n", fmtEuro(baseAfterInsurance), fmtEuro(afterInsurance))) sb.WriteString(fmt.Sprintf("Amount due: **%s**\n\n", fmtEuro(afterInsurance))) default: sb.WriteString(fmt.Sprintf("Amount due: **%s**\n\n", fmtEuro(afterInsurance))) } sb.WriteString("Pay now? (yes / no)\n\n") sb.WriteString("*Note: Declining payment will result in discharge to the natural respawn queue. " + "You'll be back tomorrow. The chair in the waiting room is available in the meantime.*") // Store pending interaction p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "hospital_pay", Data: &advPendingHospitalPay{ Cost: afterInsurance, Discounted: haggleOutcome == haggleWin, Waived: haggleOutcome == haggleCrit, }, ExpiresAt: time.Now().Add(advDMResponseWindow), }) p.advMarkMenuSent(ctx.Sender) return p.SendDM(ctx.Sender, sb.String()) } // ── Payment Resolution ───────────────────────────────────────────────────── func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *advPendingInteraction) error { reply := strings.TrimSpace(strings.ToLower(ctx.Body)) if reply == "yes" || reply == "y" { // Acquire lock mu := p.advUserLock(ctx.Sender) mu.Lock() defer mu.Unlock() // Reload fresh (TOCTOU protection) char, err := loadAdvCharacter(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Something went wrong loading your character.") } // Already alive (or timer expired while they were deciding)? if char.Alive { return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) } if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) { char.Alive = true char.DeadUntil = nil _ = saveAdvCharacter(char) if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil { dnd.HPCurrent = dnd.HPMax _ = SaveDnDCharacter(dnd) } return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) } // Recompute cost from current DnD level (don't trust cached value). // The haggle outcome was pinned at prompt time — re-applying it here // preserves the discount even if level changed mid-flow, and prevents // any reroll exploit. _, cost := hospitalCostsForUser(char) waived := false if pay, ok := interaction.Data.(*advPendingHospitalPay); ok { switch { case pay.Waived: cost = 0 waived = true case pay.Discounted: cost /= 2 } } // Check balance balance := p.euro.GetBalance(ctx.Sender) if balance < float64(cost) { text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke") p.SendDM(ctx.Sender, text) gr := gamesRoom() if gr != "" { name, _ := loadDisplayName(char.UserID) p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name)) } return nil } // Debit (skipped on a nat-20 waiver) if !waived { if !p.euro.Debit(ctx.Sender, float64(cost), "hospital_revival") { text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke") return p.SendDM(ctx.Sender, text) } // Community health fund: 10% of revival cost. if potCut := int(math.Round(float64(cost) * 0.1)); potCut > 0 { communityPotAdd(potCut) trackTaxPaid(ctx.Sender, potCut) } } // Revive. Restore DnDCharacter HP to full (canonical post-L5 path); // AdvCharacter.Alive flip + HospitalVisits++ propagate through the // saveAdvCharacter fan-out into player_meta. char.Alive = true char.DeadUntil = nil char.HospitalVisits++ if err := saveAdvCharacter(char); err != nil { slog.Error("hospital: failed to save character after revival", "user", char.UserID, "err", err) // Refund on save failure (no-op when nothing was debited) if !waived { p.euro.Credit(ctx.Sender, float64(cost), "hospital_revival_refund") } return p.SendDM(ctx.Sender, "Something went wrong during recovery. Your payment has been refunded.") } if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil { dnd.HPCurrent = dnd.HPMax if err := SaveDnDCharacter(dnd); err != nil { slog.Error("hospital: failed to restore DnDCharacter HP", "user", char.UserID, "err", err) } } // Discharge DM if waived { p.SendDM(ctx.Sender, "✅ **Discharged — you're alive!**\n\n"+ "Nurse Joy waved the deductible entirely. _\"On the house, sweetie. "+ "You earned it.\"_ Derek the billing orderly looks personally offended.\n\n"+ "Go get 'em. Type `!adventure` when you're ready.") } else { p.SendDM(ctx.Sender, fmt.Sprintf( "✅ **Discharged — you're alive!**\n\n"+ "%s deducted. Nurse Joy wishes you the best. "+ "She means it. She always means it.\n\n"+ "Go get 'em. Type `!adventure` when you're ready.", fmtEuro(cost))) } // Room announcement gr := gamesRoom() if gr != "" { name, _ := loadDisplayName(char.UserID) p.SendMessage(gr, fmt.Sprintf(hospitalDischargeAnnounce, name)) } return nil } // Declined or anything else — back to the ditch p.SendDM(ctx.Sender, "Understood. Nurse Joy nods cheerfully and signals the orderlies. "+ "You're wheeled back to where they found you.\n\n"+ "Natural respawn will occur in due time. Rest up.") char, err := loadAdvCharacter(ctx.Sender) if err == nil { gr := gamesRoom() if gr != "" { name, _ := loadDisplayName(char.UserID) p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name)) } } return nil } // ── Hospital Ad (sent after death) ───────────────────────────────────────── func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) { beforeInsurance, afterInsurance := hospitalCostsForUser(char) respawnTime := "unknown" if char.DeadUntil != nil { respawnTime = char.DeadUntil.Format("15:04") } text := fmt.Sprintf( "🏥 **St. Guildmore's Memorial Hospital**\n\n"+ "Nurse Joy has been notified. A bed is being prepared.\n\n"+ "Type `!hospital` to check in for same-day revival.\n"+ "Estimated bill: %s (Guild insurance covers %s → you pay %s)\n\n"+ "Or rest up — natural respawn at %s UTC.", fmtEuro(beforeInsurance), fmtEuro(beforeInsurance-afterInsurance), fmtEuro(afterInsurance), respawnTime) time.AfterFunc(10*time.Second, func() { if err := p.SendDM(userID, text); err != nil { slog.Error("hospital: failed to send hospital ad", "user", userID, "err", err) return } // Schedule nudge for 2 hours from now — checked by hospitalNudgeTicker p.hospitalNudges.Store(string(userID), time.Now().UTC().Add(2*time.Hour)) }) } // ── Hospital Nudge Ticker ────────────────────────────────────────────────── // Runs every minute, checks for nudges that are due. No long-lived goroutines. func (p *AdventurePlugin) hospitalNudgeTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-p.stopCh: return case <-ticker.C: now := time.Now().UTC() p.hospitalNudges.Range(func(key, val any) bool { uid := key.(string) nudgeAt := val.(time.Time) if now.Before(nudgeAt) { return true } // Due — remove regardless of outcome p.hospitalNudges.Delete(uid) userID := id.UserID(uid) char, err := loadAdvCharacter(userID) if err != nil || char.Alive { return true } // Don't nudge if already in hospital flow if v, ok := p.pending.Load(uid); ok { if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" { return true } } text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge") if err := p.SendDM(userID, text); err != nil { slog.Error("hospital: failed to send nudge", "user", userID, "err", err) } return true }) } } }