Hospital: cut revival to 5k/level + add CHA haggle roll

Drops the after-insurance revival cost from level × 50k to level × 5k
(before-insurance still 5×). On check-in, Nurse Joy now lets the player
attempt a CHA save vs DC 15:
  - nat 1  → auto-fail, full price
  - <  15  → fail, full price
  - >= 15  → pass, bill halved
  - nat 20 → bill fully waived (no debit, no community-pot cut)
Outcome is pinned on the pending interaction (Discounted/Waived) so the
TOCTOU recompute at confirm preserves the discount/waiver and can't be
re-rolled. Bill DM reordered: itemized → after-insurance (pre-haggle) →
🎲 roll line + Nurse Joy reaction → final amount with strikethrough on
the pre-haggle figure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 18:26:06 -07:00
parent 7ec78f3cd9
commit 2454f5f865
2 changed files with 176 additions and 35 deletions

View File

@@ -52,6 +52,41 @@ var nurseJoyNudge = []string{
"Type `!hospital` to check in.", "Type `!hospital` to check in.",
} }
// nurseJoyHaggleFlavor — Nurse Joy's reaction to the player's CHA haggle roll.
// Indexed by hospitalHaggleOutcome (haggleFumble=0, haggleFail=1, haggleWin=2, haggleCrit=3).
var nurseJoyHaggleFlavor = [][]string{
// haggleFumble — nat 1
{
"_you have drooled on the clipboard_ Oh sweetie. _gently dabs at it_ That's okay! We see this all the time. _has not seen this all the time._ Full price, but on the house emotionally!",
"You opened your mouth and a single, low groan came out. _she nods supportively_ I'll take that as 'fine with the full bill!' Wonderful negotiating!",
"_you tried to wink and both eyes closed_ Bless. We'll just go with the standard rate, sugar. Don't strain yourself.",
"You attempted charm. You produced a wet, rattling exhale. _Nurse Joy beams._ \"That's a yes to the full price! Great chat!\"",
"_you mumbled what you thought was a compliment about her hair_ That was a tube. That was the oxygen tube, honey. Full price!",
},
// haggleFail — d20+CHA below DC
{
"_smiles, doesn't blink_ I hear you, I do, but our pricing is set by the regional billing council and they are not sentimental people. Full bill it is!",
"That was a nice try! Really. _pats your hand_ Unfortunately I don't actually have the authority to discount anything, but it's so cute that you asked!",
"_tilts head sympathetically_ Oh, I would if I could! I really would. _she would not_ Standard rate, sugar.",
"Mm, no, that one didn't quite land for me. _still smiling_ But you're so brave for trying! Standard pricing applies.",
},
// haggleWin — pass
{
"_leans in conspiratorially_ You know what? You've been so polite. _types something_ I'm going to push this through as a 'goodwill adjustment.' Half off! Don't tell Derek.",
"_genuinely charmed_ Oh you're sweet. Okay, okay, I can flag this as a hardship case. Fifty percent off! _winks, has never winked at anyone before_",
"You know, the system has this little box for 'compassionate review' and I'm just _click_ going to go ahead and check that. There. Half price! Our little secret.",
"_giggles_ You're trouble. Okay — half off, but only because I like you, and because Derek is on lunch.",
},
// haggleCrit — nat 20
{
"_completely disarmed_ Oh my god. _puts down clipboard_ I haven't laughed like that on shift in YEARS. You know what? This one's on the house. Derek is going to have a complete meltdown and I do not care.",
"_dabs at her eye, recovering_ Sir. Ma'am. Whatever-you-are. That was the single most charming thing a corpse has ever said to me. Bill: waived. Walk it off.",
"_actually blushing_ I — okay. Okay! You know what? Free. It's free. _whispers to the ceiling_ Sorry Derek.",
"_stares at you for a long moment_ I'm calling it a clinical trial. You're the trial. There is no charge. Get out of here before I change my mind. _winks for real this time_",
"_to the orderlies_ Tear up the bill. _to you_ You, sugar, are a treasure. Discharge papers, no charge, complimentary water for the road.",
},
}
var nurseJoyAlive = []string{ var nurseJoyAlive = []string{
"_looks up from clipboard_ You don't appear to be dead! That's great news! We love when people aren't dead. Come back if that changes! _cheerfully_", "_looks up from clipboard_ You don't appear to be dead! That's great news! We love when people aren't dead. Come back if that changes! _cheerfully_",
"_tilts head_ Our records show you're... alive? That's wonderful! We don't actually treat alive people here. It's a whole different department. _waves vaguely_", "_tilts head_ Our records show you're... alive? That's wonderful! We don't actually treat alive people here. It's a whole different department. _waves vaguely_",

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
"math/rand/v2"
"strings" "strings"
"time" "time"
@@ -13,24 +14,58 @@ import (
// ── Pending Interaction Types ────────────────────────────────────────────── // ── Pending Interaction Types ──────────────────────────────────────────────
type advPendingHospitalPay struct { type advPendingHospitalPay struct {
Cost int64 // after-insurance amount (recomputed at confirm time for TOCTOU safety) 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 // hospitalCostsForUser returns (beforeInsurance, afterInsurance) for the
// hospital revival bill. Phase L4a switches the formula off CombatLevel // hospital revival bill. `Level × 5_000` after insurance, 5× that before.
// onto DnDCharacter.Level: `Level × 50_000` after insurance, 5× that // Floors at level 1.
// before. Post-L5g every legacy player has a DnDCharacter row, so the
// CombatLevel-derived fallback has been retired. Floors at level 1.
func hospitalCostsForUser(char *AdventureCharacter) (int64, int64) { func hospitalCostsForUser(char *AdventureCharacter) (int64, int64) {
level := 1 level := 1
if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 { if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 {
level = dnd.Level level = dnd.Level
} }
after := int64(level) * 50_000 after := int64(level) * 5_000
before := after * 5 before := after * 5
return before, after 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 ─────────────────────────────────────────────────────── // ── Hospital Command ───────────────────────────────────────────────────────
func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
@@ -62,8 +97,19 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
} }
// Compute costs (Phase L4a: DnD level × 50k after insurance, 5× before) // Compute costs (level × 5k after insurance, 5× before)
beforeInsurance, afterInsurance := hospitalCostsForUser(char) 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 // Check balance
balance := p.euro.GetBalance(ctx.Sender) balance := p.euro.GetBalance(ctx.Sender)
@@ -100,29 +146,61 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
sb.WriteString(admission) sb.WriteString(admission)
sb.WriteString("\n\n") sb.WriteString("\n\n")
// Act 2 — The Bill // Act 2 — The Bill (always shows the un-haggled after-insurance figure;
sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, visits, isHol)) // the haggle adjustment is announced separately below).
sb.WriteString(generateItemizedBill(beforeInsurance, baseAfterInsurance, visits, isHol))
sb.WriteString("\n\n") sb.WriteString("\n\n")
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery") delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
sb.WriteString(delivery) sb.WriteString(delivery)
sb.WriteString("\n\n───\n\n") sb.WriteString("\n\n───\n\n")
// Act 3 — After Insurance // Act 3 — After Insurance (pre-haggle figure)
afterText, _ := advPickFlavor(nurseJoyAfterInsurance, ctx.Sender, "hospital_after") afterText, _ := advPickFlavor(nurseJoyAfterInsurance, ctx.Sender, "hospital_after")
// Some entries have two %d placeholders (she says the number twice) // Some entries have two %d placeholders (she says the number twice)
count := strings.Count(afterText, "%d") count := strings.Count(afterText, "%d")
switch count { switch count {
case 1: case 1:
afterText = fmt.Sprintf(afterText, afterInsurance) afterText = fmt.Sprintf(afterText, baseAfterInsurance)
case 2: case 2:
afterText = fmt.Sprintf(afterText, afterInsurance, afterInsurance) afterText = fmt.Sprintf(afterText, baseAfterInsurance, baseAfterInsurance)
} }
sb.WriteString(afterText) sb.WriteString(afterText)
sb.WriteString("\n\n") sb.WriteString("\n\n───\n\n")
// Payment prompt // Act 4 — The Haggle. Nurse Joy lets you make a charisma save on your bill.
sb.WriteString(fmt.Sprintf("**St. Guildmore's Memorial Hospital**\nAmount due: **%s**\n\n", fmtEuro(afterInsurance))) 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("Pay now? (yes / no)\n\n")
sb.WriteString("*Note: Declining payment will result in discharge to the natural respawn queue. " + 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.*") "You'll be back tomorrow. The chair in the waiting room is available in the meantime.*")
@@ -130,7 +208,11 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
// Store pending interaction // Store pending interaction
p.pending.Store(string(ctx.Sender), &advPendingInteraction{ p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "hospital_pay", Type: "hospital_pay",
Data: &advPendingHospitalPay{Cost: afterInsurance}, Data: &advPendingHospitalPay{
Cost: afterInsurance,
Discounted: haggleOutcome == haggleWin,
Waived: haggleOutcome == haggleCrit,
},
ExpiresAt: time.Now().Add(advDMResponseWindow), ExpiresAt: time.Now().Add(advDMResponseWindow),
}) })
p.advMarkMenuSent(ctx.Sender) p.advMarkMenuSent(ctx.Sender)
@@ -170,8 +252,21 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
} }
// Recompute cost from current DnD level (don't trust cached value) // 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) _, 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 // Check balance
balance := p.euro.GetBalance(ctx.Sender) balance := p.euro.GetBalance(ctx.Sender)
@@ -187,16 +282,18 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
return nil return nil
} }
// Debit // Debit (skipped on a nat-20 waiver)
if !p.euro.Debit(ctx.Sender, float64(cost), "hospital_revival") { if !waived {
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke") if !p.euro.Debit(ctx.Sender, float64(cost), "hospital_revival") {
return p.SendDM(ctx.Sender, text) text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
} return p.SendDM(ctx.Sender, text)
}
// Community health fund: 10% of revival cost. // Community health fund: 10% of revival cost.
if potCut := int(math.Round(float64(cost) * 0.1)); potCut > 0 { if potCut := int(math.Round(float64(cost) * 0.1)); potCut > 0 {
communityPotAdd(potCut) communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut) trackTaxPaid(ctx.Sender, potCut)
}
} }
// Revive. Restore DnDCharacter HP to full (canonical post-L5 path); // Revive. Restore DnDCharacter HP to full (canonical post-L5 path);
@@ -207,8 +304,10 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
char.HospitalVisits++ char.HospitalVisits++
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("hospital: failed to save character after revival", "user", char.UserID, "err", err) slog.Error("hospital: failed to save character after revival", "user", char.UserID, "err", err)
// Refund on save failure // Refund on save failure (no-op when nothing was debited)
p.euro.Credit(ctx.Sender, float64(cost), "hospital_revival_refund") 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.") 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 { if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil {
@@ -219,12 +318,19 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
} }
// Discharge DM // Discharge DM
p.SendDM(ctx.Sender, fmt.Sprintf( if waived {
"✅ **Discharged — you're alive!**\n\n"+ p.SendDM(ctx.Sender, "✅ **Discharged — you're alive!**\n\n"+
"%s deducted. Nurse Joy wishes you the best. "+ "Nurse Joy waved the deductible entirely. _\"On the house, sweetie. "+
"She means it. She always means it.\n\n"+ "You earned it.\"_ Derek the billing orderly looks personally offended.\n\n"+
"Go get 'em. Type `!adventure` when you're ready.", "Go get 'em. Type `!adventure` when you're ready.")
fmtEuro(cost))) } 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 // Room announcement
gr := gamesRoom() gr := gamesRoom()