diff --git a/internal/plugin/adventure_mischief.go b/internal/plugin/adventure_mischief.go index eff75ac..519afd2 100644 --- a/internal/plugin/adventure_mischief.go +++ b/internal/plugin/adventure_mischief.go @@ -155,6 +155,63 @@ func mischiefNextTier(key string) (mischiefTier, bool) { return mischiefTier{}, false } +// mischiefPrevTier is the rung an escalated contract came FROM. Grunt is the +// bottom: nothing escalates into it. +func mischiefPrevTier(key string) (mischiefTier, bool) { + for i, t := range mischiefTiers { + if t.Key == key && i > 0 { + return mischiefTiers[i-1], true + } + } + return mischiefTier{}, false +} + +// mischiefOutlay splits what a contract collected into the buyer's stake and the +// escalator's. It exists because `paid` carries BOTH — escalateMischiefContract +// adds the delta to it so the purse cap keeps biting — and every path that gives +// money back has to give each of them back their own. Crediting the whole of +// `paid` to the buyer turns being escalated on top of into a windfall: buy an +// elite for €350, have someone bump it to a boss (+€850), let the target extract, +// and the 90% fizzle refund hands the buyer €1080 of somebody else's money. +// +// The escalator's stake is derived, not stored. An escalation is exactly one rung +// and happens at most once (escalation_count = 0 is in the UPDATE's WHERE), so it +// is always the fee gap between the contract's tier and the one below it. +func mischiefOutlay(c *mischiefContract) (buyer, escalator int) { + if c.EscalatedBy == "" || c.EscalationCount == 0 { + return c.Paid, 0 + } + cur, okCur := mischiefTierByKey(c.Tier) + prev, okPrev := mischiefPrevTier(c.Tier) + if !okCur || !okPrev { + return c.Paid, 0 + } + delta := cur.Fee - prev.Fee + if delta <= 0 || delta > c.Paid { + return c.Paid, 0 // nonsense ladder; don't invent money out of it + } + return c.Paid - delta, delta +} + +// trackMischiefSink books a sink against the people whose money it actually was. +// The escalator's stake sits inside c.Paid, so charging the whole rake to the +// buyer would credit them with tax they never paid — and credit the escalator, +// who funded most of a boss contract, with none. +func trackMischiefSink(c *mischiefContract, amount int) { + buyerPaid, escPaid := mischiefOutlay(c) + total := buyerPaid + escPaid + if amount <= 0 || total <= 0 { + return + } + escShare := amount * escPaid / total + if escShare > 0 { + trackTaxPaid(c.EscalatedBy, escShare) + } + if buyerShare := amount - escShare; buyerShare > 0 { + trackTaxPaid(c.BuyerID, buyerShare) + } +} + // mischiefBlessingFee is what one ward costs against a contract of this fee. It // reads the contract's CURRENT basis, so an escalation raises the price of // protecting its target too — the room's counterplay tracks the threat. @@ -803,13 +860,21 @@ func (p *AdventurePlugin) mischiefBlessCmd(ctx MessageContext, fields []string) communityPotAdd(fee) trackTaxPaid(ctx.Sender, fee) - p.announceMischiefBlessing(c, ctx.Sender, c.BlessingCount+1) + // Read the tally back rather than deriving it from the pre-UPDATE snapshot: two + // blessers landing together would both compute BlessingCount+1 off a count of 0 + // and both announce "1 of 3" for a contract that is now carrying 2. + count := c.BlessingCount + 1 + if fresh, err := mischiefContractByID(c.ID); err == nil && fresh != nil { + count = fresh.BlessingCount + } + + p.announceMischiefBlessing(c, ctx.Sender, count) p.SendDM(targetID, fmt.Sprintf( "🕯️ **%s** has paid for a ward on you. Whatever's coming, I've made you harder to put down.", p.DisplayName(ctx.Sender))) return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( "🕯️ Done — **%s** goes into that fight tougher than they would have. (%d/%d wards)", - p.DisplayName(targetID), c.BlessingCount+1, mischiefBlessingCap)) + p.DisplayName(targetID), count, mischiefBlessingCap)) } // mischiefEscalateCmd — the "helping" half. Anyone but the target can pay the @@ -921,10 +986,13 @@ func (p *AdventurePlugin) dmMischiefVictim(c *mischiefContract, tier mischiefTie "😈 **Word's reached me, and you're not going to like it.**\n"+ "%s has paid to have a **%s** find you out there. It's already looking. I make it about %s.\n\n"+ "I can't call it off, and I can't come out there. What the town *can* do is ward you — "+ - "anyone who likes you can `!mischief bless @%s` (%s each, up to %d).\n"+ + "anyone who likes you can `!mischief bless %s` (%s each, up to %d).\n"+ "Walk away from it and their money is yours: **%s**. And we all find out who paid.", who, strings.ToLower(tier.Display), formatDuration(time.Until(c.WindowEndsAt)), - p.DisplayName(c.TargetID), fmtEuro(mischiefBlessingFee(c.Fee)), mischiefBlessingCap, + // The full MXID, not the display name: handleMischiefCmd splits on whitespace, + // so a copy-pasted `bless @Misty Blue` would resolve on "@Misty" — or on + // somebody else entirely. ResolveUser takes an "@user:server" verbatim. + c.TargetID, fmtEuro(mischiefBlessingFee(c.Fee)), mischiefBlessingCap, fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct)))) } diff --git a/internal/plugin/adventure_mischief_deliver.go b/internal/plugin/adventure_mischief_deliver.go index fbbc57e..0953513 100644 --- a/internal/plugin/adventure_mischief_deliver.go +++ b/internal/plugin/adventure_mischief_deliver.go @@ -141,46 +141,110 @@ func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) { // row would otherwise be immortal: the target can never be targeted again, and // the buyer's money never comes back. // -// The buyer is made whole in full rather than rake-charged. A stranded delivery -// is our crash, not a bet they lost. +// Everyone who paid is made whole in full rather than rake-charged. A stranded +// delivery is our crash, not a bet they lost — and the escalator paid too, so the +// refund splits on mischiefOutlay. +// +// The ward comes back off the sheet here as well. The crash is exactly the case +// runMischiefInterrupt's `defer clearMischiefBlessings` cannot cover, and a ward +// left behind is a permanent free cushion on the target until something else +// happens to reset TempHP. func (p *AdventurePlugin) sweepStaleMischief(now time.Time) { for _, c := range loadStaleMischiefDeliveries(now.Add(-mischiefDeliveryGrace)) { contract := c if !abandonStaleMischief(contract.ID) { continue } - p.euro.Credit(contract.BuyerID, float64(contract.Paid), "mischief_refund_stranded") + p.clearStrandedMischiefWard(&contract) + + buyerPaid, escPaid := mischiefOutlay(&contract) + p.euro.Credit(contract.BuyerID, float64(buyerPaid), "mischief_refund_stranded") p.SendDM(contract.BuyerID, fmt.Sprintf( "😾 Your **%s** never reached **%s** — something went wrong on our end. Fully refunded: %s.", - contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(contract.Paid))) + contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(buyerPaid))) + if escPaid > 0 { + p.euro.Credit(contract.EscalatedBy, float64(escPaid), "mischief_refund_stranded") + p.SendDM(contract.EscalatedBy, fmt.Sprintf( + "😾 The **%s** you paid to upgrade never reached **%s** — something went wrong on our end. "+ + "Fully refunded: %s.", + contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(escPaid))) + } slog.Warn("mischief: swept stranded delivery", "contract", contract.ID, "target", contract.TargetID) } } -// fizzleMischief refunds the buyer (minus the rake) when the monster arrives to -// an empty dungeon. The CAS is what makes the refund safe: a contract that was -// simultaneously claimed for delivery cannot also be refunded here. +// clearStrandedMischiefWard takes back a ward whose delivery died before it could +// clear its own. The amount is re-derived exactly as applyMischiefBlessings +// derived it, off the target's current MaxHP. +// +// If the crash landed BEFORE the ward was ever applied, this over-subtracts into +// a well-rested cushion the target had of their own. That window is a handful of +// DB reads wide against a whole fight, clearMischiefBlessings floors at 0, and the +// alternative — leaving a stranded ward on the sheet forever — is the strictly +// worse failure. +func (p *AdventurePlugin) clearStrandedMischiefWard(c *mischiefContract) { + if c.BlessingCount <= 0 { + return + } + dc, err := LoadDnDCharacter(c.TargetID) + if err != nil || dc == nil { + return + } + if ward := mischiefBlessingTempHP(dc.HPMax, c.BlessingCount); ward > 0 { + p.clearMischiefBlessings(c.TargetID, ward) + } +} + +// fizzleMischief refunds everyone who put money on the contract (minus the rake) +// when the monster arrives to an empty dungeon. The CAS is what makes the refund +// safe: a contract that was simultaneously claimed for delivery cannot also be +// refunded here. +// +// The buyer and the escalator are refunded SEPARATELY, off mischiefOutlay. They +// are two people who each parted with their own money, and c.Paid is the sum. func (p *AdventurePlugin) fizzleMischief(c *mischiefContract) { if !fizzleMischiefContract(c.ID) { return } - refund := int(float64(c.Paid) * mischiefFizzleRefund) - rake := c.Paid - refund - if refund > 0 { - p.euro.Credit(c.BuyerID, float64(refund), "mischief_fizzle_refund") - } - if rake > 0 { - communityPotAdd(rake) - trackTaxPaid(c.BuyerID, rake) - } + buyerPaid, escPaid := mischiefOutlay(c) + + refund, rake := p.rakedMischiefRefund(c.BuyerID, buyerPaid, "mischief_fizzle_refund") p.SendDM(c.BuyerID, fmt.Sprintf( "😾 Your **%s** got to the dungeon and found nobody home — **%s** was already out of there.\n"+ "You're refunded %s. The other %s stays with the town, for its trouble.", c.Tier, p.DisplayName(c.TargetID), fmtEuro(refund), fmtEuro(rake))) + + if escPaid > 0 { + escRefund, escRake := p.rakedMischiefRefund(c.EscalatedBy, escPaid, "mischief_fizzle_refund") + p.SendDM(c.EscalatedBy, fmt.Sprintf( + "😾 The **%s** you paid to upgrade found nobody home — **%s** was already out of there.\n"+ + "You're refunded %s. The other %s stays with the town, for its trouble.", + c.Tier, p.DisplayName(c.TargetID), fmtEuro(escRefund), fmtEuro(escRake))) + refund += escRefund + } emitMischiefFizzledNews(c, refund) } +// rakedMischiefRefund gives one stakeholder their stake back minus the fizzle +// rake, and sinks the rake against THEIR name. Returns what they got back and +// what the town kept. +func (p *AdventurePlugin) rakedMischiefRefund(who id.UserID, paid int, reason string) (refund, rake int) { + if paid <= 0 { + return 0, 0 + } + refund = int(float64(paid) * mischiefFizzleRefund) + rake = paid - refund + if refund > 0 { + p.euro.Credit(who, float64(refund), reason) + } + if rake > 0 { + communityPotAdd(rake) + trackTaxPaid(who, rake) + } + return refund, rake +} + // deliverMischief runs the purchased fight and closes it out. By the time this // is called the contract is claimed, so it resolves exactly once. func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition) error { @@ -229,13 +293,21 @@ func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition) } // refundClaimedMischief unwinds a contract that was already claimed for delivery -// but couldn't be staged. The buyer gets everything back — this is our fault, -// not a fizzle they gambled on. +// but couldn't be staged. Everyone who paid gets everything back — this is our +// fault, not a fizzle they gambled on. The escalator is a payer too; see +// mischiefOutlay. func (p *AdventurePlugin) refundClaimedMischief(c *mischiefContract, reason string) { resolveMischiefContract(c.ID, mischiefStatusFizzled, mischiefStatusFizzled) - p.euro.Credit(c.BuyerID, float64(c.Paid), "mischief_refund_unstageable") + buyerPaid, escPaid := mischiefOutlay(c) + p.euro.Credit(c.BuyerID, float64(buyerPaid), "mischief_refund_unstageable") p.SendDM(c.BuyerID, fmt.Sprintf( - "😾 Your **%s** never made it out of the gate. Fully refunded: %s.", c.Tier, fmtEuro(c.Paid))) + "😾 Your **%s** never made it out of the gate. Fully refunded: %s.", c.Tier, fmtEuro(buyerPaid))) + if escPaid > 0 { + p.euro.Credit(c.EscalatedBy, float64(escPaid), "mischief_refund_unstageable") + p.SendDM(c.EscalatedBy, fmt.Sprintf( + "😾 The **%s** you paid to upgrade never made it out of the gate. Fully refunded: %s.", + c.Tier, fmtEuro(escPaid))) + } slog.Warn("mischief: contract unstageable", "contract", c.ID, "reason", reason) } @@ -399,11 +471,13 @@ func (p *AdventurePlugin) resolveMischiefSurvived( purse := mischiefPurse(c.Fee, tier.PayoutPct) p.euro.Credit(c.TargetID, float64(purse), "mischief_survived") - // Everything the buyer spent that isn't the purse — the rake, plus the whole - // sign surcharge — is a sink. That is what makes the payout cap bite. + // Everything spent on the contract that isn't the purse — the rake, plus the + // whole sign surcharge — is a sink. That is what makes the payout cap bite. It + // is booked against the buyer AND the escalator in proportion to what each of + // them actually put in; c.Paid is the sum of the two. if rake := c.Paid - purse; rake > 0 { communityPotAdd(rake) - trackTaxPaid(c.BuyerID, rake) + trackMischiefSink(c, rake) } // Extras by tier. Treasure rolls against the zone they're actually in — the @@ -488,7 +562,7 @@ func (p *AdventurePlugin) resolveMischiefDowned( } communityPotAdd(c.Paid) - trackTaxPaid(c.BuyerID, c.Paid) + trackMischiefSink(c, c.Paid) resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned) diff --git a/internal/plugin/adventure_mischief_test.go b/internal/plugin/adventure_mischief_test.go index 055f59e..248573f 100644 --- a/internal/plugin/adventure_mischief_test.go +++ b/internal/plugin/adventure_mischief_test.go @@ -745,6 +745,55 @@ func TestMischiefEscalation_RaisesBasisAndOutlayTogether(t *testing.T) { } } +// A fizzle refunds `paid`, and after an escalation `paid` is TWO people's money. +// Refunding all of it to the buyer is a money pump: buy an elite for €350, have +// somebody bump it to a boss (+€850), let the target walk out of the dungeon, and +// the 90% refund hands the buyer €1080 — a €730 profit funded entirely by the +// escalator, who gets nothing and is never told. +func TestMischiefFizzle_RefundsTheEscalatorTheirOwnMoney(t *testing.T) { + newMischiefTestDB(t) + p, _ := mischiefWindowPlugin(t) + target := id.UserID("@hunted:x") + buyer := id.UserID("@buyer:x") + piler := id.UserID("@piler:x") + p.euro.Credit(piler, 5000, "test") + exp := seedMischiefTarget(t, target, 5, 60) + + c := seedContract(t, buyer, target, "elite", false) + elite, _ := mischiefTierByKey("elite") + boss, _ := mischiefTierByKey("boss") + delta := boss.Fee - elite.Fee + + if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil { + t.Fatalf("escalate: %v", err) + } + // They extract before the boss arrives. Nobody's monster ever lands. + if _, _, err := forcedExtractExpedition(exp.ID, "went home"); err != nil { + t.Fatalf("forcedExtractExpedition: %v", err) + } + due := time.Now().UTC().AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(12 * time.Hour) + p.fireMischiefDeliveries(due) + + if got, _ := mischiefContractByID(c.ID); got.Status != mischiefStatusFizzled { + t.Fatalf("contract status %s, want fizzled", got.Status) + } + + // Each stakeholder is refunded 90% of THEIR OWN stake — not 90% of the pooled + // `paid`, which is what the buyer would otherwise pocket. + wantBuyer := int(float64(c.Paid) * mischiefFizzleRefund) + wantPiler := int(float64(delta) * mischiefFizzleRefund) + if bal := int(p.euro.GetBalance(buyer)); bal != wantBuyer { + t.Errorf("buyer refunded %d, want %d — they were handed the escalator's stake", bal, wantBuyer) + } + if spent := 5000 - int(p.euro.GetBalance(piler)); spent != delta-wantPiler { + t.Errorf("escalator is out %d, want %d (their 10%% rake) — their refund went elsewhere", + spent, delta-wantPiler) + } + if pot, want := communityPotBalance(), (c.Paid-wantBuyer)+(delta-wantPiler); pot != want { + t.Errorf("pot raked %d, want %d", pot, want) + } +} + // Boss tier is the "end their expedition" button, and the weekly cap on it is the // target's protection. An escalation into boss is still a boss landing on them, so // it answers to the same cap — otherwise the cap is bought around for the price of