mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is semantics-preserving, and build/vet/test are green either side. Split out from the code-review fixes that follow so those stay reviewable instead of hiding inside a wall of realignment.
435 lines
15 KiB
Go
435 lines
15 KiB
Go
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)
|
||
descriptor, _ := advPickFlavor(hospitalDischargeBillDescriptors, ctx.Sender, "hospital_discharge_bill")
|
||
p.SendMessage(gr, fmt.Sprintf(hospitalDischargeAnnouncePrefix, name, descriptor))
|
||
}
|
||
|
||
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
|
||
})
|
||
}
|
||
}
|
||
}
|