From 8fc5a82b83de70505bdc2cd023312cdcd657d40a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:03:20 -0700 Subject: [PATCH] mischief: you can now pay to have someone's expedition ruined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mischief Makers M1 — the core engine, Matrix-only. `!mischief send elite @user` debits the buyer, tells the games room a hit is out, and an hour later a monster from the target's own level bracket walks into whatever dungeon they're in. Survive it and they keep a cut of the money and the buyer is named; don't, and they wake up on a cart home. The monster comes from the target's bracket zone pool, not the arena ladder and not the dungeon they happen to be standing in — the same selection code the M0 pricing sweep ran through, so the fee table can't drift away from the fight it priced. Three things that are load-bearing and don't look it: * Survival is read off the target's HP, not PlayerWon. The engine's timeout is a retreat, not a lethal blow — somebody who ran out the clock with HP left held the thing off, and a bought monster that merely outlasted them hasn't earned a maiming. * Nobody dies for money, and that includes the party. The delivery skips closeOutZoneWin/Loss (the fight is extrinsic to the dungeon — crediting it would let a buyer unlock the target's kill-gated resources for them), so nothing else floors a downed seat. Without floorMischiefRoster on BOTH outcomes, a member the leader outlived is left alive at 0 HP, which every `HPCurrent <= 0` gate reads as broken rather than dead. * One live contract per target is a partial UNIQUE INDEX, not a read-then-write check. Placement holds only the *buyer's* lock, so two buyers racing at the same victim would both pass an in-code test. The loser is refunded. Payouts are a percentage of the base fee, never of what the buyer actually paid — the sign surcharge is a pure sink. Capped at 75%, so a survival purse is always strictly less than the outlay and collusion loses to !baltransfer, which is free. That cap is the entire anti-collusion story; no danger multiplier needed. A crash between claiming a contract and closing it out used to be unrecoverable in the design: the row would strand, the target could never be targeted again, and the buyer's money was gone. The stale sweep refunds those in full — that one is our fault, not a bet they lost. Contract timestamps bind as Go time.Time, never CURRENT_TIMESTAMP. The driver stores RFC3339 and SQLite's own stamp is space-separated; the two compare lexicographically wrong. Pete learns four mischief_* event types in a separate commit, and has to deploy BEFORE this does — an unknown event_type is a 400, which retries and then parks the bulletin forever. --- internal/db/db.go | 38 + internal/plugin/adventure.go | 7 + internal/plugin/adventure_mischief.go | 766 ++++++++++++++++++ internal/plugin/adventure_mischief_deliver.go | 422 ++++++++++ internal/plugin/adventure_mischief_test.go | 564 +++++++++++++ internal/plugin/adventure_worldboss.go | 9 +- internal/plugin/adventure_worldboss_test.go | 4 +- internal/plugin/expedition_boredom.go | 2 +- .../plugin/mischief_pricing_sweep_test.go | 85 ++ 9 files changed, 1890 insertions(+), 7 deletions(-) create mode 100644 internal/plugin/adventure_mischief.go create mode 100644 internal/plugin/adventure_mischief_deliver.go create mode 100644 internal/plugin/adventure_mischief_test.go create mode 100644 internal/plugin/mischief_pricing_sweep_test.go diff --git a/internal/db/db.go b/internal/db/db.go index c850458..70b15b8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1734,6 +1734,44 @@ CREATE TABLE IF NOT EXISTS community_pot ( updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); +-- Mischief Makers: a paid monster hit on a player who is out on an expedition. +-- The buyer's euros are debited at placement (paid); fee is the payout basis the +-- target's survival purse is a percentage of — the sign surcharge is a pure sink +-- and never inflates the purse, which is what keeps a payout strictly below what +-- the buyer spent (collusion loses to !baltransfer, which is free). +-- +-- Lifecycle: open → delivering → delivered (outcome survived|downed) | fizzled. +-- Every transition is a conditional UPDATE, so a double-fire of the ticker or a +-- restart mid-delivery can never pay twice. No bootstrap — absent row == no +-- contract in flight. +CREATE TABLE IF NOT EXISTS mischief_contracts ( + contract_id TEXT PRIMARY KEY, + buyer_id TEXT NOT NULL, + target_id TEXT NOT NULL, + tier TEXT NOT NULL, + fee INTEGER NOT NULL, + paid INTEGER NOT NULL, + status TEXT NOT NULL, + signed INTEGER NOT NULL DEFAULT 0, + escalation_count INTEGER NOT NULL DEFAULT 0, + blessing_count INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'matrix', + outcome TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + window_ends_at DATETIME NOT NULL, + resolved_at DATETIME +); +-- One live contract per target, enforced by the database rather than by a +-- read-then-write check. The placement path holds only the BUYER's lock, so two +-- different buyers racing to hit the same person would both pass an in-code +-- "is one already live?" test. This partial unique index is what actually stops +-- the second insert; the loser is refunded. +CREATE UNIQUE INDEX IF NOT EXISTS idx_mischief_one_live_per_target + ON mischief_contracts(target_id) WHERE status IN ('open', 'delivering'); +CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id, status); +CREATE INDEX IF NOT EXISTS idx_mischief_buyer ON mischief_contracts(buyer_id, created_at); +CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_at); + -- Babysitting Service CREATE TABLE IF NOT EXISTS adventure_babysit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index d717e38..7d1733e 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -170,6 +170,7 @@ func (p *AdventurePlugin) Commands() []CommandDef { {Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm ", Category: "Games"}, {Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll ", Category: "Games"}, {Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"}, + {Name: "mischief", Description: "Pay to send a monster after an adventurer who's out on an expedition", Usage: "!mischief send @user", Category: "Games"}, {Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"}, {Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"}, {Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast [--upcast N] [--target @user]", Category: "Games"}, @@ -288,6 +289,7 @@ func (p *AdventurePlugin) Init() error { go p.peteRosterTicker() go p.expeditionExtractionSweepTicker() go p.expeditionBoredomTicker() + go p.mischiefTicker() // Auto-cashout any arena runs left in 'awaiting' from a prior restart p.arenaCleanupStaleRuns() @@ -362,6 +364,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "duel") { return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel")) } + if p.IsCommand(ctx.Body, "mischief") { + return p.handleMischiefCmd(ctx, p.GetArgs(ctx.Body, "mischief")) + } if p.IsCommand(ctx.Body, "arm") { return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm")) } @@ -543,6 +548,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleRivalsCmd(ctx) case lower == "duel" || strings.HasPrefix(lower, "duel "): return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):])) + case lower == "mischief" || strings.HasPrefix(lower, "mischief "): + return p.handleMischiefCmd(ctx, strings.TrimSpace(args[len("mischief"):])) case lower == "babysit" || strings.HasPrefix(lower, "babysit "): return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit"))) case lower == "blacksmith" || lower == "repair": diff --git a/internal/plugin/adventure_mischief.go b/internal/plugin/adventure_mischief.go new file mode 100644 index 0000000..0ff1ffa --- /dev/null +++ b/internal/plugin/adventure_mischief.go @@ -0,0 +1,766 @@ +package plugin + +// Mischief Makers (M1) — paid monster hits on live expeditions. +// +// A buyer spends euros to send a monster at an adventurer who is out in a +// dungeon right now. The games room hears the contract is out; a window later +// the monster is delivered mid-run. Surviving it pays the target and unseals +// the buyer in Pete's bulletin. See gogobee_mischief_plan.md. +// +// Three properties the design leans on, all of them enforced here rather than +// trusted: +// +// - The monster is priced against the TARGET, not the dungeon they happen to +// be standing in. It comes from the zone pool of the target's own level +// bracket (mischiefBracketZones), which is the corpus the difficulty pass is +// calibrated on. The arena ladder is deliberately NOT the source: its +// BaseLethality is a legacy flat death chance and arena T5 one-shots a level +// 20, which would collapse every tier into an execution. +// +// - The payout basis is the BASE fee, never what the buyer actually paid. The +// +25% sign surcharge is a pure sink. With the per-tier percentages below +// capped at 75%, a survival purse is always strictly less than the buyer's +// outlay — so two players colluding to move money this way lose to +// `!baltransfer`, which is free. That cap is the whole anti-collusion story; +// no danger multiplier is needed. +// +// - Mischief maims, it does not murder. The delivery close-out floors every +// seat at 1 HP and force-extracts the expedition. A purchased attack that +// lands while the victim is asleep must not be able to delete their +// character — see mischiefResolveDowned. + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "math" + "math/rand/v2" + "strings" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "github.com/google/uuid" + "maunium.net/go/mautrix/id" +) + +const ( + // mischiefWindow — how long the games room has to react before the monster + // is delivered. Long enough for a bless/escalate scramble (M2), short + // enough that a contract resolves inside one sitting. + mischiefWindow = 60 * time.Minute + + // mischiefMinLevel — a level 1-2 adventurer is nobody's idea of a fair + // target, and the bracket-1 pool is the only thing we could send anyway. + mischiefMinLevel = 3 + + // mischiefTargetCooldown — a target gets a breather after one resolves, so + // a buyer with money can't chain-maim someone out of the game. + mischiefTargetCooldown = 24 * time.Hour + + // mischiefBuyerDailyCap — contracts one buyer may place per rolling day. + mischiefBuyerDailyCap = 2 + + // mischiefBossPerTargetWindow — boss tier is "end this expedition" against a + // caster (0-14% survival in the M0 sweep). One per target per week, on top of + // the cooldown and the buyer cap. + mischiefBossPerTargetWindow = 7 * 24 * time.Hour + + // mischiefSignSurcharge — the buyer pays this much on top to put their name + // on the contract from the start. Pure sink; see the payout-basis note above. + mischiefSignSurcharge = 0.25 + + // Fizzle: the expedition ended before the monster could find them. + mischiefFizzleRefund = 0.90 +) + +// Contract statuses. `delivering` is the claimed-but-unresolved state a CAS +// moves through, so a ticker double-fire finds nothing left to claim. +const ( + mischiefStatusOpen = "open" + mischiefStatusDelivering = "delivering" + mischiefStatusDelivered = "delivered" + mischiefStatusFizzled = "fizzled" +) + +const ( + mischiefOutcomeSurvived = "survived" + mischiefOutcomeDowned = "downed" +) + +// ── Tiers ──────────────────────────────────────────────────────────────────── + +// mischiefTier is one rung of the grunt→boss ladder. Fee and PayoutPct come +// from the M0 pricing sweep (n=2000/cell against the real combat engine) read +// against the live prod economy — see the tier table in the plan doc. +type mischiefTier struct { + Key string + Display string + Fee int // base fee; also the payout basis + PayoutPct float64 // survival purse as a fraction of Fee. Capped at 0.75. + Blurb string +} + +var mischiefTiers = []mischiefTier{ + {Key: "grunt", Display: "Grunt", Fee: 40, PayoutPct: 0.40, + Blurb: "One of the local nasties. Mostly theatre — it'll cost them blood, not the run."}, + {Key: "mob", Display: "Mob", Fee: 100, PayoutPct: 0.50, + Blurb: "Three of them, back to back. Attrition, and a real dent in the supplies."}, + {Key: "elite", Display: "Elite", Fee: 350, PayoutPct: 0.65, + Blurb: "Something that hunts on purpose, and it opens with a free swing. A coin-flip at their level."}, + {Key: "boss", Display: "Boss", Fee: 1200, PayoutPct: 0.75, + Blurb: "The worst thing in their bracket, with the drop on them. This ends expeditions."}, +} + +func mischiefTierByKey(key string) (mischiefTier, bool) { + for _, t := range mischiefTiers { + if t.Key == strings.ToLower(strings.TrimSpace(key)) { + return t, true + } + } + return mischiefTier{}, false +} + +// mischiefSignedFee is what a buyer pays to put their name on the contract up +// front. The extra is a sink — mischiefPurse never sees it. +func mischiefSignedFee(fee int) int { + return int(math.Round(float64(fee) * (1 + mischiefSignSurcharge))) +} + +// mischiefPurse is the target's cut for surviving: a percentage of the BASE fee +// (which escalations raise, and the sign surcharge does not). Hard-capped at 75% +// so no combination of tier + escalation can pay out more than three-quarters of +// what was spent. +func mischiefPurse(fee int, pct float64) int { + if pct > 0.75 { + pct = 0.75 + } + return int(math.Round(float64(fee) * pct)) +} + +// ── Monster selection ──────────────────────────────────────────────────────── + +// mischiefBracketZones — the zone whose enemy pool a target of each level +// bracket draws its attacker from. The target's *current* dungeon is not used: +// a level 20 slumming in a tier 1 zone should still be sent something that can +// find them, and a level 4 who wandered into a tier 4 zone should not be sent +// its boss. +var mischiefBracketZones = map[int]ZoneID{ + 1: ZoneGoblinWarrens, + 2: ZoneSunkenTemple, + 3: ZoneManorBlackspire, + 4: ZoneUnderdark, + 5: ZoneDragonsLair, +} + +func mischiefBracketForLevel(level int) int { + switch { + case level <= 3: + return 1 + case level <= 7: + return 2 + case level <= 12: + return 3 + case level <= 17: + return 4 + } + return 5 +} + +// mischiefBracketZone resolves the zone a target's attacker is drawn from. +func mischiefBracketZone(level int) ZoneDefinition { + zone, _ := getZone(mischiefBracketZones[mischiefBracketForLevel(level)]) + return zone +} + +func mischiefPickEnemy(zone ZoneDefinition, elite bool, rng *rand.Rand) DnDMonsterTemplate { + var pool []ZoneEnemy + for _, e := range zone.Enemies { + if e.IsElite == elite { + pool = append(pool, e) + } + } + if len(pool) == 0 { + pool = zone.Enemies + } + if len(pool) == 0 { + return DnDMonsterTemplate{} + } + return dndBestiary[pool[rng.IntN(len(pool))].BestiaryID] +} + +// mischiefMonsters returns the fight chain a tier sends, and whether the first +// monster gets the ambush (the attacker's privilege — it was lying in wait). +// This IS the shape the M0 sweep priced; changing it invalidates the fee table. +// +// grunt = one non-elite zone enemy, no ambush +// mob = three non-elite zone enemies back to back (HP carries), no ambush +// elite = one elite zone enemy, ambush +// boss = the zone boss, ambush +func mischiefMonsters(tierKey string, zone ZoneDefinition, rng *rand.Rand) (chain []DnDMonsterTemplate, ambush bool) { + switch tierKey { + case "grunt": + return []DnDMonsterTemplate{mischiefPickEnemy(zone, false, rng)}, false + case "mob": + return []DnDMonsterTemplate{ + mischiefPickEnemy(zone, false, rng), + mischiefPickEnemy(zone, false, rng), + mischiefPickEnemy(zone, false, rng), + }, false + case "elite": + return []DnDMonsterTemplate{mischiefPickEnemy(zone, true, rng)}, true + case "boss": + return []DnDMonsterTemplate{dndBestiary[zone.Boss.BestiaryID]}, true + } + return nil, false +} + +// ── Model + persistence ────────────────────────────────────────────────────── + +type mischiefContract struct { + ID string + BuyerID id.UserID + TargetID id.UserID + Tier string + Fee int // payout basis (base fee + escalations) + Paid int // what the buyer actually parted with + Status string + Signed bool + EscalationCount int + BlessingCount int + Source string + Outcome string + CreatedAt time.Time + WindowEndsAt time.Time +} + +const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed, + escalation_count, blessing_count, source, COALESCE(outcome, ''), created_at, window_ends_at` + +func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error) { + c := &mischiefContract{} + err := row.Scan(&c.ID, &c.BuyerID, &c.TargetID, &c.Tier, &c.Fee, &c.Paid, &c.Status, + &c.Signed, &c.EscalationCount, &c.BlessingCount, &c.Source, &c.Outcome, + &c.CreatedAt, &c.WindowEndsAt) + if err != nil { + return nil, err + } + return c, nil +} + +// insertMischiefContract writes the contract, and FAILS if one is already live +// against this target (idx_mischief_one_live_per_target). That error is the +// serialization point for two buyers racing at the same victim — the caller must +// refund on it, not ignore it. +// +// Every timestamp is bound as a Go time.Time rather than left to +// CURRENT_TIMESTAMP: created_at and window_ends_at are both compared against +// bound Go times later (the daily cap, the due sweep), and mixing SQLite's own +// text stamp with the driver's encoding is how you get a comparison that is +// lexicographic-by-accident. +func insertMischiefContract(c *mischiefContract) error { + _, err := db.Get().Exec( + `INSERT INTO mischief_contracts + (contract_id, buyer_id, target_id, tier, fee, paid, status, signed, source, + created_at, window_ends_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, string(c.BuyerID), string(c.TargetID), c.Tier, c.Fee, c.Paid, + c.Status, c.Signed, c.Source, c.CreatedAt, c.WindowEndsAt) + return err +} + +// claimMischiefForDelivery moves open → delivering and reports whether THIS +// caller won the race. Exactly one delivery per contract, whatever the ticker +// does across a restart. +func claimMischiefForDelivery(contractID string) bool { + res := db.ExecResult("mischief: claim delivery", + `UPDATE mischief_contracts SET status = ? + WHERE contract_id = ? AND status = ?`, + mischiefStatusDelivering, contractID, mischiefStatusOpen) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +// resolveMischiefContract stamps the terminal state. Called only by the +// delivery path, which already holds the claim. +func resolveMischiefContract(contractID, status, outcome string) { + db.Exec("mischief: resolve contract", + `UPDATE mischief_contracts + SET status = ?, outcome = ?, resolved_at = ? + WHERE contract_id = ?`, + status, outcome, time.Now().UTC(), contractID) +} + +// fizzleMischiefContract claims an open contract straight to fizzled — the +// target's expedition ended before the monster found them. Same CAS discipline +// as delivery, so a fizzle and a delivery can never both fire. +func fizzleMischiefContract(contractID string) bool { + res := db.ExecResult("mischief: fizzle contract", + `UPDATE mischief_contracts + SET status = ?, outcome = ?, resolved_at = ? + WHERE contract_id = ? AND status = ?`, + mischiefStatusFizzled, mischiefStatusFizzled, time.Now().UTC(), + contractID, mischiefStatusOpen) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +// loadStaleMischiefDeliveries returns contracts stuck in `delivering` — claimed +// by a delivery that then crashed, or was killed mid-fight by a restart. Without +// this sweep the row lives forever: the target can never be targeted again (the +// live-contract check counts `delivering`) and the buyer's money is simply gone. +func loadStaleMischiefDeliveries(before time.Time) []mischiefContract { + rows, err := db.Get().Query( + `SELECT `+mischiefCols+` FROM mischief_contracts + WHERE status = ? AND window_ends_at <= ?`, + mischiefStatusDelivering, before) + if err != nil { + return nil + } + defer rows.Close() + var out []mischiefContract + for rows.Next() { + if c, err := scanMischief(rows); err == nil { + out = append(out, *c) + } + } + return out +} + +// abandonStaleMischief releases a stuck delivery. CAS off `delivering`, so a +// delivery that is merely slow (a long fight) cannot be swept out from under +// itself and double-refunded. +func abandonStaleMischief(contractID string) bool { + res := db.ExecResult("mischief: abandon stale delivery", + `UPDATE mischief_contracts + SET status = ?, outcome = ?, resolved_at = ? + WHERE contract_id = ? AND status = ?`, + mischiefStatusFizzled, mischiefStatusFizzled, time.Now().UTC(), + contractID, mischiefStatusDelivering) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +// loadMischiefDue returns open contracts whose window has closed. +func loadMischiefDue(now time.Time) []mischiefContract { + rows, err := db.Get().Query( + `SELECT `+mischiefCols+` FROM mischief_contracts + WHERE status = ? AND window_ends_at <= ?`, + mischiefStatusOpen, now) + if err != nil { + return nil + } + defer rows.Close() + var out []mischiefContract + for rows.Next() { + if c, err := scanMischief(rows); err == nil { + out = append(out, *c) + } + } + return out +} + +// liveMischiefForTarget returns the contract already in flight against a target, +// or nil. One at a time: two monsters converging on the same person turns a +// mischief into a mugging. +func liveMischiefForTarget(targetID id.UserID) *mischiefContract { + row := db.Get().QueryRow( + `SELECT `+mischiefCols+` FROM mischief_contracts + WHERE target_id = ? AND status IN (?, ?) + ORDER BY created_at DESC LIMIT 1`, + string(targetID), mischiefStatusOpen, mischiefStatusDelivering) + c, err := scanMischief(row) + if err != nil { + return nil + } + return c +} + +// mischiefTargetCoolingDown reports whether the target resolved a contract too +// recently to be sent another. +func mischiefTargetCoolingDown(targetID id.UserID, now time.Time) (bool, time.Duration) { + var resolved time.Time + err := db.Get().QueryRow( + `SELECT resolved_at FROM mischief_contracts + WHERE target_id = ? AND resolved_at IS NOT NULL + ORDER BY resolved_at DESC LIMIT 1`, + string(targetID)).Scan(&resolved) + if err != nil { + return false, 0 // no row (or unreadable stamp) == not cooling down + } + if wait := mischiefTargetCooldown - now.Sub(resolved.UTC()); wait > 0 { + return true, wait + } + return false, 0 +} + +// mischiefBuyerCountSince is how many contracts this buyer has placed in the +// window — the anti-spam cap. Fizzled ones count: the limit is on how often you +// may point a monster at someone, not on how often it connects. +func mischiefBuyerCountSince(buyerID id.UserID, since time.Time) int { + var n int + _ = db.Get().QueryRow( + `SELECT COUNT(*) FROM mischief_contracts WHERE buyer_id = ? AND created_at > ?`, + string(buyerID), since).Scan(&n) + return n +} + +// mischiefBossCountSince counts boss-tier contracts aimed at a target inside the +// window, from anyone. Boss is the "end their expedition" button; one a week. +func mischiefBossCountSince(targetID id.UserID, since time.Time) int { + var n int + _ = db.Get().QueryRow( + `SELECT COUNT(*) FROM mischief_contracts + WHERE target_id = ? AND tier = 'boss' AND created_at > ?`, + string(targetID), since).Scan(&n) + return n +} + +// ── Eligibility ────────────────────────────────────────────────────────────── + +// mischiefTargetable reports why a target can't be sent a monster, or "" if they +// can. The active expedition is the whole premise: the monster has to have +// somewhere to walk in from. +func (p *AdventurePlugin) mischiefTargetable(targetID id.UserID, tier mischiefTier, now time.Time) string { + dndChar, err := LoadDnDCharacter(targetID) + if err != nil || dndChar == nil { + return fmt.Sprintf("%s has no adventurer.", p.DisplayName(targetID)) + } + // A dead character can still own an 'active' expedition row — that drift is + // exactly what the ambient ticker keeps a run-liveness net for. Don't sell a + // hit on a corpse. + if advChar, err := loadAdvCharacter(targetID); err != nil || advChar == nil || !advChar.Alive { + return fmt.Sprintf("%s is in no state to be attacked. Leave them be.", p.DisplayName(targetID)) + } + if dndChar.Level < mischiefMinLevel { + return fmt.Sprintf("%s is only level %d — nobody's putting a price on a rookie's head (level %d+).", + p.DisplayName(targetID), dndChar.Level, mischiefMinLevel) + } + exp, err := getActiveExpedition(targetID) + if err != nil || exp == nil { + return fmt.Sprintf("%s isn't out on an expedition. Monsters don't do house calls.", + p.DisplayName(targetID)) + } + if c := liveMischiefForTarget(targetID); c != nil { + return fmt.Sprintf("Somebody already has coin on %s. Wait for it to land.", p.DisplayName(targetID)) + } + if cooling, wait := mischiefTargetCoolingDown(targetID, now); cooling { + return fmt.Sprintf("%s has only just shaken one off. Try again %s.", + p.DisplayName(targetID), formatDuration(wait)) + } + if tier.Key == "boss" && mischiefBossCountSince(targetID, now.Add(-mischiefBossPerTargetWindow)) > 0 { + return fmt.Sprintf("%s has already had a boss sent after them this week. Once is plenty.", + p.DisplayName(targetID)) + } + return "" +} + +// ── Command surface ────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) handleMischiefCmd(ctx MessageContext, args string) error { + fields := strings.Fields(strings.TrimSpace(args)) + if len(fields) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, p.mischiefBoard()) + } + switch strings.ToLower(fields[0]) { + case "board", "tiers", "help": + return p.SendReply(ctx.RoomID, ctx.EventID, p.mischiefBoard()) + case "status": + return p.mischiefStatusCmd(ctx) + case "send": + return p.mischiefSendCmd(ctx, fields[1:]) + default: + return p.SendReply(ctx.RoomID, ctx.EventID, + "Unknown option. `!mischief` for the board · `!mischief send @user` · `!mischief status`") + } +} + +func (p *AdventurePlugin) mischiefBoard() string { + var b strings.Builder + b.WriteString("😈 **Mischief Makers** — put coin on an adventurer's head while they're out in a dungeon.\n") + b.WriteString("The monster comes from their own bracket, so it's always a fight worth watching. ") + b.WriteString("If they survive it, they keep a cut of your money — and the town finds out it was you.\n\n") + for _, t := range mischiefTiers { + b.WriteString(fmt.Sprintf("· **%s** — %s (signed: %s) — survivor keeps %s\n _%s_\n", + t.Display, fmtEuro(t.Fee), fmtEuro(mischiefSignedFee(t.Fee)), + fmtEuro(mischiefPurse(t.Fee, t.PayoutPct)), t.Blurb)) + } + b.WriteString("\n`!mischief send @user` — anonymous · add `signed` to put your name on it up front.\n") + b.WriteString(fmt.Sprintf("_They have to be out on an expedition, level %d+. It lands about %s later._", + mischiefMinLevel, formatDuration(mischiefWindow))) + return b.String() +} + +func (p *AdventurePlugin) mischiefStatusCmd(ctx MessageContext) error { + if c := liveMischiefForTarget(ctx.Sender); c != nil { + t, _ := mischiefTierByKey(c.Tier) + who := "Someone" + if c.Signed { + who = "**" + p.DisplayName(c.BuyerID) + "**" + } + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "😈 %s has paid for a **%s** to find you. It arrives %s. Survive it and you keep %s.", + who, strings.ToLower(t.Display), formatDuration(time.Until(c.WindowEndsAt)), + fmtEuro(mischiefPurse(c.Fee, t.PayoutPct)))) + } + rows, err := db.Get().Query( + `SELECT `+mischiefCols+` FROM mischief_contracts + WHERE buyer_id = ? AND status IN (?, ?) ORDER BY created_at DESC`, + string(ctx.Sender), mischiefStatusOpen, mischiefStatusDelivering) + if err == nil { + defer rows.Close() + var lines []string + for rows.Next() { + if c, err := scanMischief(rows); err == nil { + lines = append(lines, fmt.Sprintf("· a **%s** for **%s**, landing %s", + c.Tier, p.DisplayName(c.TargetID), formatDuration(time.Until(c.WindowEndsAt)))) + } + } + if len(lines) > 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, + "😈 **Your contracts in flight**\n"+strings.Join(lines, "\n")) + } + } + return p.SendReply(ctx.RoomID, ctx.EventID, + "Nothing out on you, and nothing out from you. `!mischief` for the board.") +} + +func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) error { + if len(fields) < 2 { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Usage: `!mischief send @user [signed]`") + } + tier, ok := mischiefTierByKey(fields[0]) + if !ok { + return p.SendReply(ctx.RoomID, ctx.EventID, + "That's not a tier. Try `grunt`, `mob`, `elite` or `boss` — `!mischief` for the board.") + } + targetID, ok := p.ResolveUser(fields[1], ctx.RoomID) + if !ok { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Could not resolve that user. Usage: `!mischief send @user`") + } + if targetID == ctx.Sender { + return p.SendReply(ctx.RoomID, ctx.EventID, + "You can't put a price on your own head. Get help elsewhere.") + } + signed := len(fields) > 2 && strings.EqualFold(fields[2], "signed") + + // Serialise this buyer's placement so a double-submit can't pass the daily + // cap twice and double-debit — same discipline as the duel escrow. + lock := p.advUserLock(ctx.Sender) + lock.Lock() + defer lock.Unlock() + + now := time.Now().UTC() + if n := mischiefBuyerCountSince(ctx.Sender, now.Add(-24*time.Hour)); n >= mischiefBuyerDailyCap { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "You've already sent %d monsters today. Even mischief keeps office hours.", n)) + } + if reason := p.mischiefTargetable(targetID, tier, now); reason != "" { + return p.SendReply(ctx.RoomID, ctx.EventID, reason) + } + + price := tier.Fee + if signed { + price = mischiefSignedFee(tier.Fee) + } + if bal := p.euro.GetBalance(ctx.Sender); int(bal) < price { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "A %s runs %s — you have %s.", strings.ToLower(tier.Display), fmtEuro(price), fmtEuro(int(bal)))) + } + if !p.euro.Debit(ctx.Sender, float64(price), "mischief_contract") { + return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.") + } + + c := &mischiefContract{ + ID: uuid.NewString(), + BuyerID: ctx.Sender, + TargetID: targetID, + Tier: tier.Key, + Fee: tier.Fee, + Paid: price, + Status: mischiefStatusOpen, + Signed: signed, + Source: "matrix", + CreatedAt: now, + WindowEndsAt: now.Add(mischiefWindow), + } + // The one-live-contract-per-target index is the real guard: the check above + // ran under this buyer's lock, and another buyer could have slipped a contract + // onto the same target since. Losing that race costs the buyer nothing. + if err := insertMischiefContract(c); err != nil { + p.euro.Credit(ctx.Sender, float64(price), "mischief_refund_contested") + slog.Warn("mischief: contract insert rejected", + "buyer", ctx.Sender, "target", targetID, "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "Somebody beat you to **%s** — there's already a monster on the way. You've been refunded.", + p.DisplayName(targetID))) + } + + p.announceMischiefContract(c, tier) + emitMischiefContractNews(c, tier) + + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "😈 Done. A **%s** is on its way to **%s** — it finds them in about %s.\n%s", + strings.ToLower(tier.Display), p.DisplayName(targetID), formatDuration(mischiefWindow), + mischiefBuyerNote(signed))) +} + +func mischiefBuyerNote(signed bool) string { + if signed { + return "_Your name is on it. Everyone already knows._" + } + return "_Nobody knows it was you — unless they walk away from it._" +} + +// ── Announcements ──────────────────────────────────────────────────────────── + +// announceMischiefContract tells the games room a hit is out. The buyer is named +// only if they paid to sign it; otherwise this is the whole point of the window — +// the room knows someone did it and doesn't know who. +func (p *AdventurePlugin) announceMischiefContract(c *mischiefContract, tier mischiefTier) { + gr := gamesRoom() + if gr == "" { + return + } + who := "**Someone in town**" + if c.Signed { + who = fmt.Sprintf("**%s**", p.DisplayName(c.BuyerID)) + } + p.SendMessage(gr, fmt.Sprintf( + "😈 %s has put %s on **%s**'s head — a **%s** is already looking for them out there.\n"+ + "It finds them in about %s. If they walk away from it, they keep %s of that money — and we all find out who paid.", + who, fmtEuro(c.Paid), p.DisplayName(c.TargetID), strings.ToLower(tier.Display), + formatDuration(time.Until(c.WindowEndsAt)), fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct)))) +} + +func (p *AdventurePlugin) announceMischiefSurvived(c *mischiefContract, monsterName string, purse int) { + gr := gamesRoom() + if gr == "" { + return + } + // The unseal. Anonymity is the buyer's to lose, and losing it is what makes + // a survival worth announcing. + p.SendMessage(gr, fmt.Sprintf( + "🛡️ **%s walked away from it.** The **%s** that came for them is dead, and %s is %s richer for the trouble.\n"+ + "The coin came from **%s**.", + p.DisplayName(c.TargetID), monsterName, p.DisplayName(c.TargetID), fmtEuro(purse), + p.DisplayName(c.BuyerID))) +} + +func (p *AdventurePlugin) announceMischiefDowned(c *mischiefContract, monsterName string) { + gr := gamesRoom() + if gr == "" { + return + } + who := "Whoever paid for it" + if c.Signed { + who = fmt.Sprintf("**%s**, who paid for it,", p.DisplayName(c.BuyerID)) + } + p.SendMessage(gr, fmt.Sprintf( + "💀 **%s didn't walk away.** A **%s** found them mid-run and put them on the floor. "+ + "They're being carried home — expedition over, pockets lighter.\n%s keeps nothing: the money's gone to the community pot.", + p.DisplayName(c.TargetID), monsterName, who)) +} + +// ── Pete news ──────────────────────────────────────────────────────────────── +// +// Deploy Pete BEFORE gogobee whenever these types change: Pete 400s an unknown +// event_type, gogobee retries, and the bulletin parks forever. + +func emitMischiefContractNews(c *mischiefContract, tier mischiefTier) { + target := charName(c.TargetID) + if target == "" { + return + } + buyer := "" + buyerUser := id.UserID("") + if c.Signed { + buyer = charName(c.BuyerID) + buyerUser = c.BuyerID + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("mischief_contract:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts), + EventType: "mischief_contract", + Tier: "priority", + Subject: target, + Opponent: buyer, + Boss: tier.Display, + Stakes: fmtEuro(c.Paid), + Level: charLevel(c.TargetID), + OccurredAt: ts, + }, c.TargetID, buyerUser) +} + +// emitMischiefResolvedNews files the survival or the maiming. On a survival the +// buyer is named whether or not they signed — that is the unseal, and it is the +// only brake on casual griefing. (`!news optout` still anonymizes them to "an +// adventurer", as it does everywhere else.) +func emitMischiefResolvedNews(c *mischiefContract, monsterName, outcome string, purse int) { + target := charName(c.TargetID) + if target == "" { + return + } + eventType := "mischief_downed" + buyer, buyerUser := "", id.UserID("") + if outcome == mischiefOutcomeSurvived { + eventType = "mischief_survived" + buyer, buyerUser = charName(c.BuyerID), c.BuyerID + } else if c.Signed { + buyer, buyerUser = charName(c.BuyerID), c.BuyerID + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("%s:%s:%d", eventType, eventToken(c.TargetID, "mischief:"+c.ID), ts), + EventType: eventType, + Tier: "priority", + Subject: target, + Opponent: buyer, + Boss: monsterName, + Stakes: fmtEuro(purse), + Level: charLevel(c.TargetID), + Outcome: outcome, + OccurredAt: ts, + }, c.TargetID, buyerUser) +} + +func emitMischiefFizzledNews(c *mischiefContract, refund int) { + target := charName(c.TargetID) + if target == "" { + return + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("mischief_fizzled:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts), + EventType: "mischief_fizzled", + Tier: "bulletin", + Subject: target, + Stakes: fmtEuro(refund), + Outcome: "fizzled", + OccurredAt: ts, + }, c.TargetID, "") +} + +// mischiefContractByID is the test/ops read path. +func mischiefContractByID(contractID string) (*mischiefContract, error) { + row := db.Get().QueryRow( + `SELECT `+mischiefCols+` FROM mischief_contracts WHERE contract_id = ?`, contractID) + c, err := scanMischief(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return c, err +} diff --git a/internal/plugin/adventure_mischief_deliver.go b/internal/plugin/adventure_mischief_deliver.go new file mode 100644 index 0000000..82c2d45 --- /dev/null +++ b/internal/plugin/adventure_mischief_deliver.go @@ -0,0 +1,422 @@ +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 + target := contract.TargetID + + exp, err := getActiveExpedition(target) + if err != nil || exp == nil || exp.Status != ExpeditionStatusActive { + p.fizzleMischief(&contract) + continue + } + // 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) + continue + } + } + if hasActiveCombatSession(target) { + continue // they're mid-fight; the monster can wait a minute + } + if !claimMischiefForDelivery(contract.ID) { + continue // 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) + + _ = abandonZoneRun(c.TargetID) + _ = retireAllRegionRuns(exp) + _, 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) +} diff --git a/internal/plugin/adventure_mischief_test.go b/internal/plugin/adventure_mischief_test.go new file mode 100644 index 0000000..88d6b98 --- /dev/null +++ b/internal/plugin/adventure_mischief_test.go @@ -0,0 +1,564 @@ +package plugin + +import ( + "math/rand/v2" + "testing" + "time" + + "gogobee/internal/db" + + "github.com/google/uuid" + "maunium.net/go/mautrix/id" +) + +func newMischiefTestDB(t *testing.T) { + t.Helper() + db.Close() + if err := db.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) +} + +func seedContract(t *testing.T, buyer, target id.UserID, tier string, signed bool) *mischiefContract { + t.Helper() + td, ok := mischiefTierByKey(tier) + if !ok { + t.Fatalf("unknown tier %q", tier) + } + paid := td.Fee + if signed { + paid = mischiefSignedFee(td.Fee) + } + c := &mischiefContract{ + ID: uuid.NewString(), + BuyerID: buyer, + TargetID: target, + Tier: td.Key, + Fee: td.Fee, + Paid: paid, + Status: mischiefStatusOpen, + Signed: signed, + Source: "matrix", + CreatedAt: time.Now().UTC(), + WindowEndsAt: time.Now().UTC().Add(mischiefWindow), + } + if err := insertMischiefContract(c); err != nil { + t.Fatalf("seed contract (%s → %s): %v", buyer, target, err) + } + return c +} + +// The anti-collusion invariant, and the only reason no danger multiplier is +// needed: whatever the tier and whether or not the buyer signed, the target's +// survival purse is strictly less than what the buyer parted with. Two players +// who try to move money this way lose to !baltransfer, which is free. +func TestMischiefPurseNeverExceedsOutlay(t *testing.T) { + for _, tier := range mischiefTiers { + purse := mischiefPurse(tier.Fee, tier.PayoutPct) + for _, paid := range []int{tier.Fee, mischiefSignedFee(tier.Fee)} { + if purse >= paid { + t.Errorf("%s: purse %d >= buyer outlay %d — collusion beats !baltransfer", + tier.Key, purse, paid) + } + } + if tier.PayoutPct > 0.75 { + t.Errorf("%s: payout %.2f exceeds the 75%% cap", tier.Key, tier.PayoutPct) + } + } +} + +// The cap is enforced in code, not just in the table — an escalation (M2) raises +// the payout basis, and nothing it can do may push the purse past 75%. +func TestMischiefPurseHardCap(t *testing.T) { + if got := mischiefPurse(1000, 0.95); got != 750 { + t.Fatalf("mischiefPurse(1000, 0.95) = %d, want 750 (capped)", got) + } +} + +func TestMischiefSignedFee(t *testing.T) { + // The tier table's signed prices, as priced in M0. + want := map[string]int{"grunt": 50, "mob": 125, "elite": 438, "boss": 1500} + for _, tier := range mischiefTiers { + if got := mischiefSignedFee(tier.Fee); got != want[tier.Key] { + t.Errorf("%s signed fee = %d, want %d", tier.Key, got, want[tier.Key]) + } + } +} + +// Exactly one of delivery and fizzle may claim a contract, however many ticks +// race for it. Without this a restart mid-delivery could both maim the target +// and refund the buyer. +func TestMischiefClaimIsExactlyOnce(t *testing.T) { + newMischiefTestDB(t) + c := seedContract(t, "@buyer:x", "@target:x", "elite", false) + + if !claimMischiefForDelivery(c.ID) { + t.Fatal("first claim should win") + } + if claimMischiefForDelivery(c.ID) { + t.Error("second claim won — a contract could be delivered twice") + } + if fizzleMischiefContract(c.ID) { + t.Error("fizzle won after the delivery claim — buyer would be refunded a hit that landed") + } + + c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false) + if !fizzleMischiefContract(c2.ID) { + t.Fatal("first fizzle should win") + } + if claimMischiefForDelivery(c2.ID) { + t.Error("delivery claimed a fizzled contract — buyer would pay twice") + } +} + +// Only a closed window is due, and a resolved contract never comes back. +func TestMischiefDueWindow(t *testing.T) { + newMischiefTestDB(t) + now := time.Now().UTC() + c := seedContract(t, "@buyer:x", "@target:x", "mob", false) + + if due := loadMischiefDue(now); len(due) != 0 { + t.Fatalf("contract is due before its window closed: %d", len(due)) + } + due := loadMischiefDue(now.Add(mischiefWindow + time.Minute)) + if len(due) != 1 || due[0].ID != c.ID { + t.Fatalf("want the contract due after its window; got %d", len(due)) + } + + resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived) + if due := loadMischiefDue(now.Add(2 * mischiefWindow)); len(due) != 0 { + t.Error("a resolved contract came back around") + } +} + +// One live contract per target: two monsters converging on the same person is a +// mugging, not mischief. +func TestMischiefOneLiveContractPerTarget(t *testing.T) { + newMischiefTestDB(t) + target := id.UserID("@target:x") + if liveMischiefForTarget(target) != nil { + t.Fatal("no contract seeded, but one is live") + } + c := seedContract(t, "@buyer:x", target, "grunt", false) + if live := liveMischiefForTarget(target); live == nil || live.ID != c.ID { + t.Fatal("open contract should be live") + } + // A claimed-but-unresolved contract still blocks — the monster is en route. + claimMischiefForDelivery(c.ID) + if liveMischiefForTarget(target) == nil { + t.Error("a delivering contract should still block a second one") + } + resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned) + if liveMischiefForTarget(target) != nil { + t.Error("a resolved contract still reads as live") + } +} + +// The post-resolution breather. Also the regression guard for the modernc +// datetime hazard: resolved_at round-trips through the driver's own encoding +// (bound Go time in, time.Time out), so the row has to be a real one — a +// hand-written stamp in some other format would compare lexicographically and +// silently never expire. +func TestMischiefTargetCooldown(t *testing.T) { + newMischiefTestDB(t) + target := id.UserID("@target:x") + now := time.Now().UTC() + + if cooling, _ := mischiefTargetCoolingDown(target, now); cooling { + t.Fatal("a target with no history is cooling down") + } + c := seedContract(t, "@buyer:x", target, "grunt", false) + resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived) + + cooling, wait := mischiefTargetCoolingDown(target, now) + if !cooling { + t.Fatal("a just-resolved target should be cooling down") + } + // resolved_at is stamped a hair after `now`, so allow a second of slack. + if wait <= 0 || wait > mischiefTargetCooldown+time.Second { + t.Errorf("wait = %s, want within (0, %s]", wait, mischiefTargetCooldown) + } + if cooling, _ := mischiefTargetCoolingDown(target, now.Add(mischiefTargetCooldown+time.Minute)); cooling { + t.Error("still cooling down after the cooldown elapsed") + } +} + +// Rate limits: contracts per buyer per day, and boss-tier per target per week. +// Fizzled contracts count against the buyer — the limit is on how often you may +// point a monster at someone, not on how often it connects. +func TestMischiefRateLimits(t *testing.T) { + newMischiefTestDB(t) + buyer := id.UserID("@buyer:x") + now := time.Now().UTC() + dayAgo := now.Add(-24 * time.Hour) + + if n := mischiefBuyerCountSince(buyer, dayAgo); n != 0 { + t.Fatalf("fresh buyer count = %d, want 0", n) + } + c1 := seedContract(t, buyer, "@a:x", "grunt", false) + seedContract(t, buyer, "@b:x", "mob", false) + fizzleMischiefContract(c1.ID) + if n := mischiefBuyerCountSince(buyer, dayAgo); n != mischiefBuyerDailyCap { + t.Errorf("buyer count = %d, want %d (a fizzle still counts)", n, mischiefBuyerDailyCap) + } + + target := id.UserID("@whale:x") + weekAgo := now.Add(-mischiefBossPerTargetWindow) + if n := mischiefBossCountSince(target, weekAgo); n != 0 { + t.Fatalf("fresh boss count = %d, want 0", n) + } + elite := seedContract(t, "@other:x", target, "elite", false) + if n := mischiefBossCountSince(target, weekAgo); n != 0 { + t.Error("an elite contract counted against the boss-per-week limit") + } + // Resolve it first: only one contract may be live against a target at a time, + // and the database enforces that (idx_mischief_one_live_per_target). + resolveMischiefContract(elite.ID, mischiefStatusDelivered, mischiefOutcomeSurvived) + seedContract(t, "@other:x", target, "boss", false) + if n := mischiefBossCountSince(target, weekAgo); n != 1 { + t.Errorf("boss count = %d, want 1", n) + } +} + +// The database, not a read-then-write check, is what stops two buyers racing a +// monster at the same target. The placement path holds only the buyer's lock, so +// without this index both inserts would land and the target would be hit twice. +func TestMischiefSecondLiveContractIsRejected(t *testing.T) { + newMischiefTestDB(t) + target := id.UserID("@target:x") + seedContract(t, "@buyer1:x", target, "grunt", false) + + second := &mischiefContract{ + ID: uuid.NewString(), BuyerID: "@buyer2:x", TargetID: target, + Tier: "elite", Fee: 350, Paid: 350, Status: mischiefStatusOpen, + Source: "matrix", CreatedAt: time.Now().UTC(), + WindowEndsAt: time.Now().UTC().Add(mischiefWindow), + } + if err := insertMischiefContract(second); err == nil { + t.Fatal("a second live contract was accepted — the target can be hit twice at once") + } +} + +// A delivery that dies mid-flight (restart, panic) must not strand the contract: +// the row would block the target forever and the buyer's money would be gone. +func TestMischiefStaleDeliverySweep(t *testing.T) { + newMischiefTestDB(t) + now := time.Now().UTC() + c := seedContract(t, "@buyer:x", "@target:x", "boss", false) + if !claimMischiefForDelivery(c.ID) { + t.Fatal("claim should win") + } + + // Inside the grace period a slow delivery is left alone. + if stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow)); len(stale) != 0 { + t.Errorf("a delivery still inside its grace window was called stranded: %d", len(stale)) + } + + stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow + mischiefDeliveryGrace + time.Minute)) + if len(stale) != 1 || stale[0].ID != c.ID { + t.Fatalf("stranded delivery not found: %d", len(stale)) + } + if !abandonStaleMischief(c.ID) { + t.Fatal("abandon should win on a delivering row") + } + if abandonStaleMischief(c.ID) { + t.Error("abandon won twice — the buyer would be refunded twice") + } + // And the target is free again. + if liveMischiefForTarget("@target:x") != nil { + t.Error("a swept contract still blocks the target") + } +} + +// The monster is priced against the TARGET's bracket, not the dungeon they are +// standing in — that is what the M0 fee table measured. +func TestMischiefBracketZones(t *testing.T) { + for _, tc := range []struct { + level int + tier ZoneTier + }{{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3}, {13, 4}, {17, 4}, {18, 5}, {20, 5}} { + zone := mischiefBracketZone(tc.level) + if zone.Tier != tc.tier { + t.Errorf("level %d draws from tier %d, want %d", tc.level, zone.Tier, tc.tier) + } + } +} + +// The fight shapes the fee table was priced against. Changing any of these +// invalidates the pricing, so they are pinned. +func TestMischiefMonsterChains(t *testing.T) { + zone := mischiefBracketZone(10) // tier 3 + rng := rand.New(rand.NewPCG(0x6d69, 1)) + + for _, tc := range []struct { + tier string + wantLen int + wantAmbush bool + }{ + {"grunt", 1, false}, + {"mob", 3, false}, + {"elite", 1, true}, + {"boss", 1, true}, + } { + chain, ambush := mischiefMonsters(tc.tier, zone, rng) + if len(chain) != tc.wantLen { + t.Errorf("%s: chain of %d, want %d", tc.tier, len(chain), tc.wantLen) + } + if ambush != tc.wantAmbush { + t.Errorf("%s: ambush = %v, want %v", tc.tier, ambush, tc.wantAmbush) + } + for _, m := range chain { + if m.Name == "" { + t.Errorf("%s: chain holds an empty monster — the bestiary lookup missed", tc.tier) + } + } + } + + // Boss tier sends the zone's actual boss; elite sends something flagged elite. + if chain, _ := mischiefMonsters("boss", zone, rng); chain[0].ID != zone.Boss.BestiaryID { + t.Errorf("boss tier sent %q, want the zone boss %q", chain[0].ID, zone.Boss.BestiaryID) + } + elite, _ := mischiefMonsters("elite", zone, rng) + found := false + for _, e := range zone.Enemies { + if e.BestiaryID == elite[0].ID && e.IsElite { + found = true + } + } + if !found { + t.Errorf("elite tier sent %q, which isn't an elite in %s", elite[0].ID, zone.ID) + } +} + +// ── End-to-end: the delivery path against a real character ─────────────────── + +// seedMischiefTarget builds a real, playable adventurer on a live expedition — +// the thing a contract actually lands on. Unit tests on the contract row prove +// nothing about the fight; these drive runMischiefInterrupt and the close-outs. +func seedMischiefTarget(t *testing.T, uid id.UserID, level, hp int) *Expedition { + t.Helper() + if err := createAdvCharacter(uid, "target"+uid.Localpart()); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if err := SaveDnDCharacter(&DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level, + STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10, + HPMax: hp, HPCurrent: hp, ArmorClass: 18, + }); err != nil { + t.Fatalf("SaveDnDCharacter: %v", err) + } + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + if _, err := db.Get().Exec(` + INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, coins_earned) + VALUES (?, ?, 'goblin_warrens', ?, 'active', ?, 500)`, + "exp-"+uid.Localpart(), string(uid), run.RunID, time.Now().UTC()); err != nil { + t.Fatalf("seed expedition: %v", err) + } + exp, err := getActiveExpedition(uid) + if err != nil || exp == nil { + t.Fatalf("getActiveExpedition: %v", err) + } + return exp +} + +// A grunt against a healthy level-5 fighter: the fight actually runs, it costs +// HP, and they live. This is the "mostly theatre" tier doing what it was priced +// to do. +func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) { + newMischiefTestDB(t) + p := &AdventurePlugin{} + uid := id.UserID("@grunted:example.org") + exp := seedMischiefTarget(t, uid, 5, 60) + + bracket := mischiefBracketZone(5) + chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2))) + + _, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush) + if !survived { + t.Fatalf("a level-5 fighter at full HP died to a grunt (%s) — the tier is mispriced", monster) + } + hp, _ := dndHPSnapshot(uid) + if hp <= 0 { + t.Errorf("survivor is at %d HP", hp) + } +} + +// The boss tier is the dangerous one, and the one where the no-perma-death rule +// has to hold. A level-3 caster-grade sheet against a tier-1 boss goes down — +// and must come back up at 1 HP with the expedition force-extracted, never dead. +// +// This is the property that separates mischief from paid murder: it is bought, +// and it lands while the victim may well be asleep. +func TestMischiefDelivery_DownedTargetIsMaimedNotKilled(t *testing.T) { + newMischiefTestDB(t) + p := &AdventurePlugin{euro: NewEuroPlugin(nil)} + uid := id.UserID("@doomed:example.org") + // 1 HP: whatever the dice do, this fight ends with them on the floor. + exp := seedMischiefTarget(t, uid, 3, 1) + + c := seedContract(t, "@buyer:example.org", uid, "boss", false) + if !claimMischiefForDelivery(c.ID) { + t.Fatal("claim should win") + } + if err := p.deliverMischief(c, exp); err != nil { + t.Fatalf("deliverMischief: %v", err) + } + + // Alive, floored, and carried home. + char, err := loadAdvCharacter(uid) + if err != nil || char == nil { + t.Fatal("target's character is gone") + } + if !char.Alive { + t.Fatal("a PURCHASED monster killed a player outright — mischief must maim, never murder") + } + if hp, _ := dndHPSnapshot(uid); hp != 1 { + t.Errorf("downed target is at %d HP, want the 1 HP floor", hp) + } + if e, _ := getActiveExpedition(uid); e != nil { + t.Error("the expedition survived a downing — it must be force-extracted") + } + + // The buyer gets nothing back; the whole fee is a sink. + got, err := mischiefContractByID(c.ID) + if err != nil || got == nil { + t.Fatal("contract row vanished") + } + if got.Status != mischiefStatusDelivered || got.Outcome != mischiefOutcomeDowned { + t.Errorf("contract resolved as %s/%s, want delivered/downed", got.Status, got.Outcome) + } + if pot := communityPotBalance(); pot < c.Paid { + t.Errorf("community pot holds %d, want at least the %d fee — a landed hit refunds nobody", pot, c.Paid) + } + if bal := p.euro.GetBalance(c.BuyerID); bal > 0 { + t.Errorf("buyer was credited %.0f on a successful hit; glory only", bal) + } +} + +// Survival pays the target out of the buyer's money, and never more than the +// buyer spent. Driven through the real close-out, not the arithmetic helper. +func TestMischiefDelivery_SurvivalPaysTheTarget(t *testing.T) { + newMischiefTestDB(t) + p := &AdventurePlugin{euro: NewEuroPlugin(nil)} + uid := id.UserID("@tough:example.org") + exp := seedMischiefTarget(t, uid, 12, 400) // comfortably survives a tier-1 grunt + + c := seedContract(t, "@buyer:example.org", uid, "grunt", false) + if !claimMischiefForDelivery(c.ID) { + t.Fatal("claim should win") + } + before := p.euro.GetBalance(uid) + if err := p.deliverMischief(c, exp); err != nil { + t.Fatalf("deliverMischief: %v", err) + } + + got, _ := mischiefContractByID(c.ID) + if got.Outcome != mischiefOutcomeSurvived { + t.Fatalf("a level-12 fighter with 400 HP lost to a tier-1 grunt (outcome %s)", got.Outcome) + } + tier, _ := mischiefTierByKey("grunt") + purse := mischiefPurse(tier.Fee, tier.PayoutPct) + if gained := int(p.euro.GetBalance(uid) - before); gained != purse { + t.Errorf("survivor was paid %d, want the %d purse", gained, purse) + } + if purse >= c.Paid { + t.Errorf("purse %d >= outlay %d", purse, c.Paid) + } + // The remainder is a sink, not a rebate. + if pot := communityPotBalance(); pot != c.Paid-purse { + t.Errorf("pot holds %d, want the %d remainder", pot, c.Paid-purse) + } + // The expedition is untouched — they fought it off and walk on. + if e, _ := getActiveExpedition(uid); e == nil { + t.Error("surviving a contract ended the expedition") + } +} + +// An expedition that ends before the window closes fizzles: the buyer is +// refunded 90%, the town rakes the rest, and nobody is attacked. +func TestMischiefDelivery_FizzlesWhenTargetWentHome(t *testing.T) { + newMischiefTestDB(t) + p := &AdventurePlugin{euro: NewEuroPlugin(nil)} + uid := id.UserID("@gone:example.org") + seedMischiefTarget(t, uid, 5, 60) + + c := seedContract(t, "@buyer:example.org", uid, "elite", false) + // They extract before the monster arrives. + if _, _, err := forcedExtractExpedition("exp-"+uid.Localpart(), "went home"); err != nil { + t.Fatalf("forcedExtractExpedition: %v", err) + } + + p.fireMischiefDeliveries(time.Now().UTC().Add(mischiefWindow + time.Minute)) + + got, _ := mischiefContractByID(c.ID) + if got.Status != mischiefStatusFizzled { + t.Fatalf("contract status %s, want fizzled — the dungeon was empty", got.Status) + } + refund := int(float64(c.Paid) * mischiefFizzleRefund) + if bal := int(p.euro.GetBalance(c.BuyerID)); bal != refund { + t.Errorf("buyer refunded %d, want %d (90%%)", bal, refund) + } + if pot := communityPotBalance(); pot != c.Paid-refund { + t.Errorf("pot raked %d, want %d", pot, c.Paid-refund) + } + if hp, max := dndHPSnapshot(uid); hp != max { + t.Errorf("a fizzled contract still hurt the target (%d/%d HP)", hp, max) + } +} + +// The limbo state. A leader can win a fight their friend went down in — and a +// mischief delivery deliberately skips the close-outs that would normally mark a +// 0-HP seat dead. So the seat must be floored on BOTH outcomes, or the member is +// left alive at 0 HP: not dead enough for the hospital, too dead to act, and +// read as broken by every gate that tests `HPCurrent <= 0`. +func TestMischiefDelivery_SurvivalFloorsADroppedPartyMember(t *testing.T) { + newMischiefTestDB(t) + p := &AdventurePlugin{euro: NewEuroPlugin(nil)} + leader := id.UserID("@leader:example.org") + member := id.UserID("@member:example.org") + + exp := seedMischiefTarget(t, leader, 12, 400) // wins comfortably + seatLeaderFixture(t, exp.ID) + if err := createAdvCharacter(member, "member"); err != nil { + t.Fatal(err) + } + if err := SaveDnDCharacter(&DnDCharacter{ + UserID: member, Race: RaceHuman, Class: ClassFighter, Level: 3, + STR: 14, DEX: 12, CON: 12, INT: 10, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: 30, ArmorClass: 12, + }); err != nil { + t.Fatal(err) + } + if err := joinParty(exp.ID, member); err != nil { + t.Fatalf("joinParty: %v", err) + } + // Put the member on the floor the way the fight would. + if _, err := db.Get().Exec( + `UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(member)); err != nil { + t.Fatal(err) + } + + c := seedContract(t, "@buyer:example.org", leader, "grunt", false) + if !claimMischiefForDelivery(c.ID) { + t.Fatal("claim should win") + } + if err := p.deliverMischief(c, exp); err != nil { + t.Fatalf("deliverMischief: %v", err) + } + + got, _ := mischiefContractByID(c.ID) + if got.Outcome != mischiefOutcomeSurvived { + t.Fatalf("leader lost; this test needs a survival (outcome %s)", got.Outcome) + } + hp, _ := dndHPSnapshot(member) + if hp <= 0 { + t.Errorf("dropped party member left at %d HP on a survived contract — alive, but stuck at zero", hp) + } + if mc, _ := loadAdvCharacter(member); mc == nil || !mc.Alive { + t.Error("a bought monster killed a party member — mischief maims, it never murders") + } +} diff --git a/internal/plugin/adventure_worldboss.go b/internal/plugin/adventure_worldboss.go index 475a538..f0e5cb9 100644 --- a/internal/plugin/adventure_worldboss.go +++ b/internal/plugin/adventure_worldboss.go @@ -695,7 +695,7 @@ func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBoss // No death / no hospital: floor the fighter at 1 HP so a loss never reads as // a corpse. runZoneCombat already persisted the real HP cost. - battered := worldBossFloorHP(userID) + battered := floorHPAtOne(userID) dmg := result.EnemyEntryHP - result.EnemyEndHP if dmg < 0 { @@ -718,10 +718,11 @@ func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBoss }, nil } -// worldBossFloorHP raises a player to 1 HP if the bout left them at zero, and +// floorHPAtOne raises a player to 1 HP if the fight left them at zero, and // reports whether it had to. Keeps "no death" honest without undoing the real -// HP cost of a bout the player mostly survived. -func worldBossFloorHP(userID id.UserID) bool { +// HP cost of a bout the player mostly survived. Shared by the two no-death +// fights: the world-boss bout and a Mischief Makers delivery. +func floorHPAtOne(userID id.UserID) bool { cur, _ := dndHPSnapshot(userID) if cur > 0 { return false diff --git a/internal/plugin/adventure_worldboss_test.go b/internal/plugin/adventure_worldboss_test.go index 8a3c550..59a2fe4 100644 --- a/internal/plugin/adventure_worldboss_test.go +++ b/internal/plugin/adventure_worldboss_test.go @@ -291,13 +291,13 @@ func TestWorldBossFloorHP(t *testing.T) { newWorldBossTestDB(t) uid := id.UserID("@floor:test.invalid") fightableChar(t, uid) - if worldBossFloorHP(uid) { + if floorHPAtOne(uid) { t.Error("floor should be a no-op above 0 HP") } if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil { t.Fatal(err) } - if !worldBossFloorHP(uid) { + if !floorHPAtOne(uid) { t.Error("floor should raise a 0-HP fighter") } if cur, _ := dndHPSnapshot(uid); cur != 1 { diff --git a/internal/plugin/expedition_boredom.go b/internal/plugin/expedition_boredom.go index 590a212..9af0b9f 100644 --- a/internal/plugin/expedition_boredom.go +++ b/internal/plugin/expedition_boredom.go @@ -88,7 +88,7 @@ var advActionCommands = map[string]bool{ "explore": true, "extract": true, "fight": true, "fish": true, "flee": true, "forage": true, "give": true, "graveyard": true, "hospital": true, "level": true, "lore": true, "map": true, - "mine": true, "news": true, "prepare": true, "region": true, + "mine": true, "mischief": true, "news": true, "prepare": true, "region": true, "resources": true, "respec": true, "rest": true, "resume": true, "revisit": true, "rivals": true, "roll": true, "scavenge": true, "sell": true, "setup": true, "sheet": true, "spells": true, diff --git a/internal/plugin/mischief_pricing_sweep_test.go b/internal/plugin/mischief_pricing_sweep_test.go new file mode 100644 index 0000000..8474669 --- /dev/null +++ b/internal/plugin/mischief_pricing_sweep_test.go @@ -0,0 +1,85 @@ +package plugin + +// M0 pricing sweep for gogobee_mischief_plan.md — single-fight survival per +// mischief tier, measured against the class-balance harness builds. Skip-gated: +// it is a pricing instrument, not a regression test. +// +// It drives the SAME production selection code a live delivery does +// (mischiefBracketZone + mischiefMonsters), so the fee table can't silently +// drift away from the fight it was priced against. What it does NOT share is the +// delivery path itself: this measures a full-HP build in a single chain, which +// is optimistic — a real target is wounded mid-run, and may have a party. The +// plan's M1 close-out item is to re-run this through runMischiefInterrupt. +// +// Run: MISCHIEF_SWEEP=1 go test ./internal/plugin -run TestMischiefPricingSweep -v + +import ( + "fmt" + "math/rand/v2" + "os" + "testing" +) + +// one trial: full-HP build fights the tier's chain; survival = won every fight. +func mischiefTrial(p classBalanceProfile, tierKey string, rng *rand.Rand) (survived bool, endHPPct float64) { + c := buildHarnessCharacter(p) + zone := mischiefBracketZone(p.Level) + chain, ambush := mischiefMonsters(tierKey, zone, rng) + hp := c.HPMax + for i, m := range chain { + if ambush && i == 0 { + hp -= clampSurpriseNick(surpriseRoundNick(m, int(zone.Tier)), hp, c.HPMax) + } + player := buildHarnessPlayer(c) + if hp < c.HPMax { + player.Stats.StartHP = hp + } + enemy := buildHarnessZoneEnemy(m, int(zone.Tier)) + if spell, slot, ok := pickBestDamageSpell(c); ok { + applyHarnessSpellCast(c, spell, slot, &player.Stats, &player.Mods, &enemy.Stats) + } + res := SimulateCombat(player, enemy, dungeonCombatPhases) + if !res.PlayerWon { + return false, 0 + } + hp = res.PlayerEndHP + } + return true, float64(hp) / float64(c.HPMax) +} + +func TestMischiefPricingSweep(t *testing.T) { + if os.Getenv("MISCHIEF_SWEEP") == "" { + t.Skip("set MISCHIEF_SWEEP=1 to run") + } + const trials = 2000 + rng := rand.New(rand.NewPCG(20260713, 0x6D69736368696566)) + profiles := []classBalanceProfile{ + {Class: ClassPaladin, Level: 1}, + {Class: ClassMage, Level: 4}, + {Class: ClassMage, Level: 8, Subclass: SubclassEvocation}, + {Class: ClassFighter, Level: 12, Subclass: SubclassChampion}, + {Class: ClassRogue, Level: 20, Subclass: SubclassArcaneTrickster}, + {Class: ClassCleric, Level: 20, Subclass: SubclassLifeDomain}, + } + fmt.Printf("%-28s %-8s %8s %10s\n", "profile", "tier", "survive%", "avgEndHP%") + for _, p := range profiles { + for _, tier := range mischiefTiers { + wins := 0 + hpSum := 0.0 + for i := 0; i < trials; i++ { + ok, hpPct := mischiefTrial(p, tier.Key, rng) + if ok { + wins++ + hpSum += hpPct + } + } + avgHP := 0.0 + if wins > 0 { + avgHP = hpSum / float64(wins) * 100 + } + fmt.Printf("%-28s %-8s %7.1f%% %9.1f%%\n", + fmt.Sprintf("%s L%d %s", p.Class, p.Level, p.Subclass), tier.Key, + float64(wins)/trials*100, avgHP) + } + } +}