mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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_",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user