From 65a48b4bd7aaf49d2fbc06a1f0daad68e7c1866b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:53:47 -0700 Subject: [PATCH] mischief: an escalation is two people's money, not one's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paid` carries the buyer's stake AND the escalator's delta, but every refund path handed all of it to the buyer. Buy an elite (€350), let somebody bump it to a boss (+€850), let the target walk out of the dungeon: the 90% fizzle refund pays the buyer €1080. A €730 profit, funded by the escalator, who loses €850 and is never told. Same in the unstageable and stranded-sweep refunds, at 100%. mischiefOutlay splits the pot back into its two stakes — derived, not stored: an escalation is exactly one rung and happens once, so it is always the fee gap to the tier below. Both are refunded separately, and the sink is booked against whoever actually paid it. Three more from the same review: - A crash mid-delivery stranded the ward on the target's sheet forever. clearMischiefBlessings runs on a defer, which is the one thing a crash skips — and the stale sweep exists because deliveries do die. It takes the cushion back now. - dmMischiefVictim told the target to run `!mischief bless @`. The command splits on whitespace, so "Misty Blue" resolved on "@Misty" — or on somebody else. It's the only command the ward window has. Use the MXID. - Two blessers landing together both announced "1 of 3" for a contract carrying 2; the count the CAS is authoritative for was being re-derived from a stale read. --- internal/plugin/adventure_mischief.go | 76 ++++++++++- internal/plugin/adventure_mischief_deliver.go | 122 ++++++++++++++---- internal/plugin/adventure_mischief_test.go | 49 +++++++ 3 files changed, 219 insertions(+), 28 deletions(-) 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