Files
gogobee/internal/plugin/adventure_mischief_deliver.go
prosolis 94077a47ae mischief: a contract that never landed shouldn't protect its target
The 24h cooldown and the one-boss-a-week cap both keyed off any resolved
contract, and a fizzle resolves. So a target could have a friend point a
grunt at them, extract, and buy a day of immunity for the fizzle rake.
Both caps now only count contracts that were actually delivered.

Delivery also ran a full combat against the target's sheet without their
advUserLock. hasActiveCombatSession only sees the turn engine — the
target's own autopilot walk resolves its fights inline under that lock
and reports no session, so a delivery could race it and lose a fight's
worth of HP writes.

Also: don't tell a buyer a rival beat them when the insert simply failed.
2026-07-13 20:21:03 -07:00

446 lines
18 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
// Mischief Makers (M1) — delivery.
//
// The sibling of the ambient ticker: once a contract's window closes, the
// monster has to actually find the target mid-run. runMischiefInterrupt is
// modelled line-for-line on runHarvestInterrupt (pick enemy → ambush nick →
// runZoneCombatRoster → close out), with two deliberate departures:
//
// - It never calls recordZoneKill and never advances zone state. The fight is
// extrinsic to the dungeon: somebody bought it, the dungeon didn't send it.
// Crediting it as a zone kill would let a buyer unlock a target's
// RequiresKill resource gates for them.
//
// - It never marks anybody dead. runHarvestInterrupt's loss path perma-kills;
// a *purchased* attack that lands while its victim is asleep must not be
// able to delete their character. Mischief maims: floor at 1 HP,
// force-extract, take a bite out of the un-banked coins.
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// mischiefTickInterval — how often we look for contracts whose window has
// closed. The window itself is the pacing; the tick just has to be fine-grained
// enough that "about an hour" isn't a lie.
const mischiefTickInterval = time.Minute
// mischiefDeliveryGrace — how long past its window a contract may sit in
// `delivering` before the sweep calls it stranded. Comfortably longer than any
// chain of auto-resolved fights takes, so a slow delivery is never swept out
// from under itself (and the CAS in abandonStaleMischief is the backstop if it
// somehow is).
const mischiefDeliveryGrace = 15 * time.Minute
// mischiefTreasureWeight / mischiefRenownXP — the survival extras. Deliberately
// modest: the purse is the reward, this is the garnish.
const (
mischiefTreasureWeight = 1.0
mischiefEliteRenownXP = 40
mischiefBossRenownXP = 120
mischiefBossCacheSize = 2
)
func (p *AdventurePlugin) mischiefTicker() {
ticker := time.NewTicker(mischiefTickInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.fireMischiefDeliveries(time.Now().UTC())
}
}
}
// fireMischiefDeliveries walks contracts whose window has closed and either
// delivers the monster or fizzles the contract.
//
// The gates mirror the ambient ticker's, for the same reason: a mid-run event
// that talks over a live turn-based fight, or lands on top of the 06:00
// briefing, reads as a bug even when it isn't. A blocked contract is NOT
// fizzled — it simply waits for the next tick. The only thing that fizzles a
// contract is the expedition ending, which is what the buyer was actually
// betting against.
func (p *AdventurePlugin) fireMischiefDeliveries(now time.Time) {
if inAmbientQuietWindow(now) {
return
}
p.sweepStaleMischief(now)
for _, c := range loadMischiefDue(now) {
contract := c
p.fireOneMischiefDelivery(&contract)
}
}
// fireOneMischiefDelivery resolves a single due contract under the TARGET's lock.
//
// The lock is not optional. hasActiveCombatSession only sees the turn-based
// engine; the target's own `!explore` / autopilot walk resolves its fights
// inline (runHarvestInterrupt → runZoneCombatRoster) under advUserLock and
// reports no session. Without taking that same lock, a delivery can run a second
// combat against the same character sheet concurrently — two LoadDnDCharacter →
// mutate → SaveDnDCharacter writers, last write wins, and a whole fight's HP cost
// vanishes. The boredom ticker takes this lock for exactly this reason.
//
// Everything below the lock runs on the auto-resolve path, which takes no user
// locks of its own, so there is nothing here to deadlock against.
func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) {
target := contract.TargetID
lock := p.advUserLock(target)
lock.Lock()
defer lock.Unlock()
// Re-read under the lock: whatever the due-sweep saw, the expedition may have
// ended while we were queuing behind the target's own command.
exp, err := getActiveExpedition(target)
if err != nil || exp == nil || exp.Status != ExpeditionStatusActive {
p.fizzleMischief(contract)
return
}
// Same safety net the ambient ticker keeps: the expedition row can still
// say 'active' after the player has functionally left the dungeon.
if exp.RunID != "" {
if run, _ := getZoneRun(exp.RunID); run == nil || !run.IsActive() {
p.fizzleMischief(contract)
return
}
}
if hasActiveCombatSession(target) {
return // they're mid-fight; the monster can wait a minute
}
if !claimMischiefForDelivery(contract.ID) {
return // another tick (or another process) already has it
}
if err := p.deliverMischief(contract, exp); err != nil {
slog.Error("mischief: delivery failed",
"contract", contract.ID, "target", target, "err", err)
}
}
// sweepStaleMischief releases contracts stranded in `delivering` — the process
// died between the claim and the close-out (a restart mid-fight, a panic). The
// 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.
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.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)))
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.
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)
}
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)))
emitMischiefFizzledNews(c, refund)
}
// 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 {
tier, ok := mischiefTierByKey(c.Tier)
if !ok {
return fmt.Errorf("unknown mischief tier %q", c.Tier)
}
target := c.TargetID
dndChar, err := LoadDnDCharacter(target)
if err != nil || dndChar == nil {
// The target evaporated between claim and delivery. Nothing to attack —
// treat it as a fizzle so the buyer isn't charged for a no-show.
p.refundClaimedMischief(c, "target has no character")
return nil
}
// The attacker comes from the target's own bracket, not the dungeon they are
// standing in. See mischiefBracketZones.
bracket := mischiefBracketZone(dndChar.Level)
rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
chain, ambush := mischiefMonsters(tier.Key, bracket, rng)
// Every link, not just the first: a zone-pool entry with no bestiary row
// yields a zero-value template, and a nameless 0-HP monster as fight 2 of a
// mob would read as the attack inexplicably giving up halfway.
if len(chain) == 0 {
p.refundClaimedMischief(c, "no monster available")
return nil
}
for _, m := range chain {
if m.Name == "" {
p.refundClaimedMischief(c, "bestiary miss in the "+bracket.Display+" pool")
return nil
}
}
narration, monsterName, survived := p.runMischiefInterrupt(target, exp, bracket, chain, ambush)
if survived {
p.resolveMischiefSurvived(c, tier, exp, bracket, monsterName, narration)
} else {
p.resolveMischiefDowned(c, exp, monsterName, narration)
}
return nil
}
// 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.
func (p *AdventurePlugin) refundClaimedMischief(c *mischiefContract, reason string) {
resolveMischiefContract(c.ID, mischiefStatusFizzled, mischiefStatusFizzled)
p.euro.Credit(c.BuyerID, float64(c.Paid), "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)))
slog.Warn("mischief: contract unstageable", "contract", c.ID, "reason", reason)
}
// runMischiefInterrupt fights the whole chain and reports whether the target was
// still standing at the end.
//
// Survival is read off the target's HP, not off PlayerWon. The engine's timeout
// is a retreat, not a lethal blow — a target who ran out the clock with HP left
// held the thing off, and a bought monster that merely *outlasted* them has not
// earned a maiming. The chain continues while they stand: a mob is three
// attackers, and beating the first two doesn't mean the third goes home.
func (p *AdventurePlugin) runMischiefInterrupt(
target id.UserID,
exp *Expedition,
bracket ZoneDefinition,
chain []DnDMonsterTemplate,
ambush bool,
) (narration, monsterName string, survived bool) {
var b strings.Builder
last := chain[len(chain)-1].Name
for i, monster := range chain {
// The ambush is the attacker's privilege — it was lying in wait, and the
// target had no reason to expect it. Same one-free-swing approximation the
// harvest interrupt uses, with the same wounded-entrant clamp so it can't
// pre-empt the fight outright.
if ambush && i == 0 {
if dc, _ := LoadDnDCharacter(target); dc != nil {
nick := clampSurpriseNick(
surpriseRoundNick(monster, int(bracket.Tier)), dc.HPCurrent, dc.HPMax)
if nick > 0 {
dc.HPCurrent -= nick
_ = SaveDnDCharacter(dc)
b.WriteString(fmt.Sprintf(
"_It was waiting for you. **%s** opens with a free swing — %d HP._\n",
monster.Name, nick))
}
}
}
preHP, _ := dndHPSnapshot(target)
pres, _, err := p.runZoneCombatRoster(
fightRoster(target), monster, int(bracket.Tier), dungeonCombatPhases, exp.DMMood)
if err != nil {
// Mid-chain build failure. The target keeps whatever HP they had; treat
// them as having survived rather than maiming them on our bug.
slog.Error("mischief: combat error", "target", target, "err", err)
b.WriteString(fmt.Sprintf("_(The %s never quite arrives.)_\n", monster.Name))
return b.String(), last, true
}
postHP, maxHP := dndHPSnapshot(target)
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
switch {
case pres.Seats[0].PlayerWon:
b.WriteString(fmt.Sprintf("✅ **%s** down — %d→%d / %d HP.\n",
monster.Name, preHP, postHP, maxHP))
case postHP > 0:
// The engine's timeout: they held it off without killing it. That still
// pays — but say so plainly, or a stalemate reads as a clean kill and the
// player wonders why the thing is still following them.
b.WriteString(fmt.Sprintf("⏳ **%s** breaks off, still breathing. You're at %d / %d HP.\n",
monster.Name, postHP, maxHP))
}
if postHP <= 0 {
return b.String(), monster.Name, false
}
if i < len(chain)-1 {
b.WriteString("_And it isn't alone._\n")
}
}
return b.String(), last, true
}
// floorMischiefRoster raises every seat the delivery put on the floor back to
// 1 HP. It runs on BOTH outcomes, and that is not belt-and-braces: the leader
// can win a fight their friend went down in, and a mischief delivery
// deliberately skips closeOutZoneWin/closeOutZoneLoss (which is what normally
// marks a 0-HP seat dead). Without this, a dropped party member is left alive at
// 0 HP — a state nothing else in the game produces, and one the gates that read
// `HPCurrent <= 0` (expedition autorun, for one) treat as broken rather than
// dead. Nobody dies for money; nobody gets stuck at zero for it either.
func (p *AdventurePlugin) floorMischiefRoster(target id.UserID) {
for _, uid := range fightRoster(target) {
if isCompanionSeat(uid) {
continue // he takes no wounds home; he has no rows to floor
}
floorHPAtOne(uid)
}
}
// resolveMischiefSurvived pays the target and unseals the buyer.
//
// The purse is a percentage of the base fee — never of what the buyer paid — so
// it is always strictly less than the buyer's outlay. See mischiefPurse.
func (p *AdventurePlugin) resolveMischiefSurvived(
c *mischiefContract, tier mischiefTier, exp *Expedition, bracket ZoneDefinition,
monsterName, narration string,
) {
// The leader lived; a friend of theirs may not have. See floorMischiefRoster.
p.floorMischiefRoster(c.TargetID)
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.
if rake := c.Paid - purse; rake > 0 {
communityPotAdd(rake)
trackTaxPaid(c.BuyerID, rake)
}
// Extras by tier. Treasure rolls against the zone they're actually in — the
// loot is scavenged off a corpse in that dungeon, wherever the thing came from.
var extras []string
switch tier.Key {
case "mob":
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
case "elite":
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
if before, after, err := addRenownXP(c.TargetID, mischiefEliteRenownXP); err == nil {
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
extras = append(extras, "The story of it is already going round town.")
}
case "boss":
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
if before, after, err := addRenownXP(c.TargetID, mischiefBossRenownXP); err == nil {
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
extras = append(extras, "People are going to be telling this one for a while.")
}
for _, item := range consumableCache(int(bracket.Tier), mischiefBossCacheSize) {
it := item
p.grantZoneItem(c.TargetID, &it, "🧪")
}
}
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
var dm strings.Builder
dm.WriteString("😈 **Somebody paid for that.**\n\n")
dm.WriteString(narration)
dm.WriteString(fmt.Sprintf(
"\n🛡 You're still standing, and the coin was never really theirs. **%s** is yours.\n",
fmtEuro(purse)))
dm.WriteString(fmt.Sprintf("It was **%s** who paid to have you killed. Do with that what you like.",
p.DisplayName(c.BuyerID)))
for _, e := range extras {
dm.WriteString("\n_" + e + "_")
}
p.SendDM(c.TargetID, dm.String())
p.SendDM(c.BuyerID, fmt.Sprintf(
"🛡️ **%s walked away from your %s.** They keep %s of your money, and the town knows your name now.",
p.DisplayName(c.TargetID), c.Tier, fmtEuro(purse)))
p.announceMischiefSurvived(c, monsterName, purse)
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeSurvived, purse)
}
// resolveMischiefDowned maims the target: HP floored at 1, expedition
// force-extracted, un-banked coins docked. Nobody dies for money.
//
// The whole fee goes to the community pot — never back to the buyer. A
// successful hit buys glory, not a rebate; refunding it would make repeat
// contracts free and turn the cap into a rounding error.
func (p *AdventurePlugin) resolveMischiefDowned(
c *mischiefContract, exp *Expedition, monsterName, narration string,
) {
// Every seat, not just the leader: a party that went down with them went down
// to a bought monster too, and the no-perma-death rule is about the purchase,
// not about who was holding the torch. Must run BEFORE the extract, which
// releases the party and would leave us nobody to floor.
p.floorMischiefRoster(c.TargetID)
// forcedExtractExpedition retires the region runs and releases the party itself;
// only the zone run has to be closed here.
_ = abandonZoneRun(c.TargetID)
_, tax, err := forcedExtractExpedition(exp.ID, "mischief contract")
if err != nil {
slog.Warn("mischief: force extract failed", "expedition", exp.ID, "err", err)
}
// The un-banked-coin loss. forcedExtractExpedition computes the 20% cut and
// leaves the debit to the caller; for a maiming, this is the caller.
if tax > 0 {
p.euro.Debit(c.TargetID, float64(tax), "mischief_downed_loss")
}
communityPotAdd(c.Paid)
trackTaxPaid(c.BuyerID, c.Paid)
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
var dm strings.Builder
dm.WriteString("😈 **Somebody paid for that.**\n\n")
dm.WriteString(narration)
dm.WriteString(fmt.Sprintf(
"\n💀 **%s** put you down. You wake up on a cart headed home — alive, which is more than the contract asked for.\n",
monsterName))
dm.WriteString("Your expedition is over.")
if tax > 0 {
dm.WriteString(fmt.Sprintf(" Whatever you hadn't banked yet is gone — %s of it.", fmtEuro(tax)))
}
if c.Signed {
dm.WriteString(fmt.Sprintf("\n\nThe contract was signed: **%s** paid for it.", p.DisplayName(c.BuyerID)))
} else {
dm.WriteString("\n\nNobody's saying who paid.")
}
p.SendDM(c.TargetID, dm.String())
p.SendDM(c.BuyerID, fmt.Sprintf(
"💀 Your **%s** found **%s** and put them on the floor. Their expedition is over.\n"+
"_You get nothing back — %s went to the community pot. You did this for the story._",
c.Tier, p.DisplayName(c.TargetID), fmtEuro(c.Paid)))
p.announceMischiefDowned(c, monsterName)
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeDowned, 0)
}