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.",
}
// 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{
"_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_",

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log/slog"
"math"
"math/rand/v2"
"strings"
"time"
@@ -14,23 +15,57 @@ import (
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. Phase L4a switches the formula off CombatLevel
// onto DnDCharacter.Level: `Level × 50_000` after insurance, 5× that
// before. Post-L5g every legacy player has a DnDCharacter row, so the
// CombatLevel-derived fallback has been retired. Floors at level 1.
// 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) * 50_000
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 {
@@ -62,8 +97,19 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
}
// Compute costs (Phase L4a: DnD level × 50k after insurance, 5× before)
beforeInsurance, afterInsurance := hospitalCostsForUser(char)
// 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)
@@ -100,29 +146,61 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
sb.WriteString(admission)
sb.WriteString("\n\n")
// Act 2 — The Bill
sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, visits, isHol))
// 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
// 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, afterInsurance)
afterText = fmt.Sprintf(afterText, baseAfterInsurance)
case 2:
afterText = fmt.Sprintf(afterText, afterInsurance, afterInsurance)
afterText = fmt.Sprintf(afterText, baseAfterInsurance, baseAfterInsurance)
}
sb.WriteString(afterText)
sb.WriteString("\n\n")
sb.WriteString("\n\n───\n\n")
// Payment prompt
sb.WriteString(fmt.Sprintf("**St. Guildmore's Memorial Hospital**\nAmount due: **%s**\n\n", fmtEuro(afterInsurance)))
// 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.*")
@@ -130,7 +208,11 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
// Store pending interaction
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "hospital_pay",
Data: &advPendingHospitalPay{Cost: afterInsurance},
Data: &advPendingHospitalPay{
Cost: afterInsurance,
Discounted: haggleOutcome == haggleWin,
Waived: haggleOutcome == haggleCrit,
},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
@@ -170,8 +252,21 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
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)
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)
@@ -187,7 +282,8 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
return nil
}
// Debit
// 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)
@@ -198,6 +294,7 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut)
}
}
// Revive. Restore DnDCharacter HP to full (canonical post-L5 path);
// AdvCharacter.Alive flip + HospitalVisits++ propagate through the
@@ -207,8 +304,10 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
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
// 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 {
@@ -219,12 +318,19 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
}
// 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()