diff --git a/internal/db/db.go b/internal/db/db.go index 70b15b8..fd34640 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -448,6 +448,10 @@ func runMigrations(d *sql.DB) error { // leader's own level — his party fled 5 runs out of 640 where the human // party fled 56. `ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`, + // Mischief M2 — whoever paid to bump a contract up a tier. Named alongside + // the buyer when the target survives (the unseal), so piling on carries the + // same exposure the original purchase does. + `ALTER TABLE mischief_contracts ADD COLUMN escalated_by TEXT`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1754,6 +1758,7 @@ CREATE TABLE IF NOT EXISTS mischief_contracts ( status TEXT NOT NULL, signed INTEGER NOT NULL DEFAULT 0, escalation_count INTEGER NOT NULL DEFAULT 0, + escalated_by TEXT, blessing_count INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT 'matrix', outcome TEXT, diff --git a/internal/plugin/adventure_mischief.go b/internal/plugin/adventure_mischief.go index 69b4fe0..f08a535 100644 --- a/internal/plugin/adventure_mischief.go +++ b/internal/plugin/adventure_mischief.go @@ -74,6 +74,21 @@ const ( // Fizzle: the expedition ended before the monster could find them. mischiefFizzleRefund = 0.90 + + // mischiefBlessingFee — what the room pays to put a ward on the target while + // the contract is open. Cheap on purpose: helping has to be an impulse, or + // nobody bothers and the window is just a countdown. + mischiefBlessingFee = 25 + + // mischiefBlessingCap — how many blessings one contract can carry. Three at + // +10% MaxHP each is a third of a health bar: enough to swing an elite + // coin-flip, not enough to make a boss survivable on its own. + mischiefBlessingCap = 3 + + // mischiefBlessingPct — temp HP per blessing, as a fraction of the target's + // MaxHP. Same cushion mechanism as a well-rested long rest (applyDnDHPScaling), + // and like it, evaporates when the fight's HP is persisted back. + mischiefBlessingPct = 0.10 ) // Contract statuses. `delivering` is the claimed-but-unresolved state a CAS @@ -123,6 +138,34 @@ func mischiefTierByKey(key string) (mischiefTier, bool) { return mischiefTier{}, false } +// mischiefNextTier is the rung an escalation buys. Boss is the top: there is +// nothing worse in the bracket to send. +func mischiefNextTier(key string) (mischiefTier, bool) { + for i, t := range mischiefTiers { + if t.Key == key && i+1 < len(mischiefTiers) { + return mischiefTiers[i+1], true + } + } + return mischiefTier{}, false +} + +// mischiefBlessingTempHP is the cushion a contract's blessings are worth to a +// target of this MaxHP. At least 1 per blessing, so a low-HP character still +// feels the ward they were bought. +func mischiefBlessingTempHP(hpMax, blessings int) int { + if blessings <= 0 || hpMax <= 0 { + return 0 + } + if blessings > mischiefBlessingCap { + blessings = mischiefBlessingCap + } + per := int(float64(hpMax) * mischiefBlessingPct) + if per < 1 { + per = 1 + } + return per * blessings +} + // 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 { @@ -229,6 +272,7 @@ type mischiefContract struct { Status string Signed bool EscalationCount int + EscalatedBy id.UserID // "" unless somebody paid to bump the tier BlessingCount int Source string Outcome string @@ -237,12 +281,13 @@ type mischiefContract struct { } 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` + escalation_count, COALESCE(escalated_by, ''), 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.Signed, &c.EscalationCount, &c.EscalatedBy, &c.BlessingCount, &c.Source, &c.Outcome, &c.CreatedAt, &c.WindowEndsAt) if err != nil { return nil, err @@ -271,6 +316,44 @@ func insertMischiefContract(c *mischiefContract) error { return err } +// blessMischiefContract adds one ward to an open contract and reports whether it +// took. The cap and the still-open check are in the WHERE clause, not in the +// caller: the window is a scramble, and a room that piles four blessings in the +// same second must still land exactly three. A caller that loses this race has +// already been debited and must refund. +func blessMischiefContract(contractID string) bool { + res := db.ExecResult("mischief: bless contract", + `UPDATE mischief_contracts SET blessing_count = blessing_count + 1 + WHERE contract_id = ? AND status = ? AND blessing_count < ?`, + contractID, mischiefStatusOpen, mischiefBlessingCap) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +// escalateMischiefContract bumps an open contract one rung: the tier, the payout +// basis (fee) and the buyer-side outlay (paid) all move together, so the purse +// stays a percentage of what was actually spent and the 75% cap survives an +// escalation untouched. +// +// The escalation_count = 0 and tier = guards are what make it one step, +// once, even if two people hit `!mischief escalate` on the same contract at the +// same moment. The loser is refunded. +func escalateMischiefContract(contractID, fromTier string, next mischiefTier, delta int, by id.UserID) bool { + res := db.ExecResult("mischief: escalate contract", + `UPDATE mischief_contracts + SET tier = ?, fee = ?, paid = paid + ?, escalation_count = 1, escalated_by = ? + WHERE contract_id = ? AND status = ? AND tier = ? AND escalation_count = 0`, + next.Key, next.Fee, delta, string(by), contractID, mischiefStatusOpen, fromTier) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + // claimMischiefForDelivery moves open → delivering and reports whether THIS // caller won the race. Exactly one delivery per contract, whatever the ticker // does across a restart. @@ -480,9 +563,14 @@ func (p *AdventurePlugin) handleMischiefCmd(ctx MessageContext, args string) err return p.mischiefStatusCmd(ctx) case "send": return p.mischiefSendCmd(ctx, fields[1:]) + case "bless": + return p.mischiefBlessCmd(ctx, fields[1:]) + case "escalate": + return p.mischiefEscalateCmd(ctx, fields[1:]) default: return p.SendReply(ctx.RoomID, ctx.EventID, - "Unknown option. `!mischief` for the board · `!mischief send @user` · `!mischief status`") + "Unknown option. `!mischief` for the board · `!mischief send @user` · "+ + "`!mischief bless @user` · `!mischief escalate @user` · `!mischief status`") } } @@ -497,6 +585,12 @@ func (p *AdventurePlugin) mischiefBoard() string { 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( + "While a contract is open, the rest of us get a say:\n"+ + "· `!mischief bless @user` — %s, up to %d — a ward for the fight. Help them.\n"+ + "· `!mischief escalate @user` — pay the difference, send something worse. \"Help\" them. "+ + "It raises their purse too, if they live.\n", + fmtEuro(mischiefBlessingFee), mischiefBlessingCap)) 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() @@ -509,10 +603,14 @@ func (p *AdventurePlugin) mischiefStatusCmd(ctx MessageContext) error { if c.Signed { who = "**" + p.DisplayName(c.BuyerID) + "**" } + wards := "" + if c.BlessingCount > 0 { + wards = fmt.Sprintf("\n🕯️ %d of the town's wards are on you.", c.BlessingCount) + } 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.", + "😈 %s has paid for a **%s** to find you. It arrives %s. Survive it and you keep %s.%s", who, strings.ToLower(t.Display), formatDuration(time.Until(c.WindowEndsAt)), - fmtEuro(mischiefPurse(c.Fee, t.PayoutPct)))) + fmtEuro(mischiefPurse(c.Fee, t.PayoutPct)), wards)) } rows, err := db.Get().Query( `SELECT `+mischiefCols+` FROM mischief_contracts @@ -617,6 +715,7 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e } p.announceMischiefContract(c, tier) + p.dmMischiefVictim(c, tier) emitMischiefContractNews(c, tier) return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( @@ -625,6 +724,141 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e mischiefBuyerNote(signed))) } +// mischiefOpenContractFor resolves "@user" to the contract currently open against +// them, or explains why the room can't act on it. A contract already claimed for +// delivery is deliberately out of reach: the monster is in the room with them and +// a ward bought now would have to reach back into a fight that has started. +func (p *AdventurePlugin) mischiefOpenContractFor(ctx MessageContext, fields []string, verb string) (*mischiefContract, id.UserID, string) { + if len(fields) < 1 { + return nil, "", fmt.Sprintf("Usage: `!mischief %s @user`", verb) + } + targetID, ok := p.ResolveUser(fields[0], ctx.RoomID) + if !ok { + return nil, "", fmt.Sprintf("Could not resolve that user. Usage: `!mischief %s @user`", verb) + } + c := liveMischiefForTarget(targetID) + if c == nil { + return nil, targetID, fmt.Sprintf("Nobody has coin on **%s** right now.", p.DisplayName(targetID)) + } + if c.Status != mischiefStatusOpen { + return nil, targetID, fmt.Sprintf( + "Too late — the thing has already found **%s**.", p.DisplayName(targetID)) + } + return c, targetID, "" +} + +// mischiefBlessCmd — the helping half of the window. The fee is a pure sink (it +// goes to the community pot, never to the purse), so a blessing can't be used to +// route money to the target: it buys them HP, not euros. +func (p *AdventurePlugin) mischiefBlessCmd(ctx MessageContext, fields []string) error { + lock := p.advUserLock(ctx.Sender) + lock.Lock() + defer lock.Unlock() + + c, targetID, reason := p.mischiefOpenContractFor(ctx, fields, "bless") + if c == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, reason) + } + if c.BlessingCount >= mischiefBlessingCap { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "**%s** is already carrying every ward the town has to give (%d). The rest is up to them.", + p.DisplayName(targetID), mischiefBlessingCap)) + } + if bal := p.euro.GetBalance(ctx.Sender); int(bal) < mischiefBlessingFee { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "A ward costs %s — you have %s.", fmtEuro(mischiefBlessingFee), fmtEuro(int(bal)))) + } + if !p.euro.Debit(ctx.Sender, float64(mischiefBlessingFee), "mischief_blessing") { + return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.") + } + // The cap is enforced by the UPDATE, not by the read above — two people can + // buy the third ward in the same second, and only one of them may have it. + if !blessMischiefContract(c.ID) { + p.euro.Credit(ctx.Sender, float64(mischiefBlessingFee), "mischief_blessing_refund") + return p.SendReply(ctx.RoomID, ctx.EventID, + "Somebody got there first — the ward wasn't needed. You've been refunded.") + } + communityPotAdd(mischiefBlessingFee) + trackTaxPaid(ctx.Sender, mischiefBlessingFee) + + p.announceMischiefBlessing(c, ctx.Sender, c.BlessingCount+1) + p.SendDM(targetID, fmt.Sprintf( + "🕯️ **%s** has paid for a ward on you. Whatever's coming, I've made you harder to put down.", + p.DisplayName(ctx.Sender))) + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "🕯️ Done — **%s** goes into that fight tougher than they would have. (%d/%d wards)", + p.DisplayName(targetID), c.BlessingCount+1, mischiefBlessingCap)) +} + +// mischiefEscalateCmd — the "helping" half. Anyone but the target can pay the +// difference to send something worse. The money joins the payout basis, so piling +// on raises the jackpot the target walks away with if they live: the room's cruelty +// is also its generosity, which is the joke. +func (p *AdventurePlugin) mischiefEscalateCmd(ctx MessageContext, fields []string) error { + lock := p.advUserLock(ctx.Sender) + lock.Lock() + defer lock.Unlock() + + c, targetID, reason := p.mischiefOpenContractFor(ctx, fields, "escalate") + if c == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, reason) + } + if targetID == ctx.Sender { + return p.SendReply(ctx.RoomID, ctx.EventID, + "You don't get to raise the price on your own head. Have some dignity.") + } + cur, _ := mischiefTierByKey(c.Tier) + next, ok := mischiefNextTier(c.Tier) + if !ok { + return p.SendReply(ctx.RoomID, ctx.EventID, + "It's already the worst thing in their bracket. There is nothing bigger to send.") + } + if c.EscalationCount > 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "Somebody has already made it worse — it's a **%s** now. Once is the limit.", + strings.ToLower(cur.Display))) + } + // An escalation into boss tier is still a boss landing on this person, so it + // answers to the same weekly limit a bought boss does. (This contract is not a + // boss yet, so it cannot count itself.) + now := time.Now().UTC() + if next.Key == "boss" && mischiefBossCountSince(targetID, now.Add(-mischiefBossPerTargetWindow)) > 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "**%s** has already had a boss sent after them this week. Let them keep this one.", + p.DisplayName(targetID))) + } + delta := next.Fee - cur.Fee + if bal := p.euro.GetBalance(ctx.Sender); int(bal) < delta { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "Bumping a %s up to a %s costs the difference: %s. You have %s.", + strings.ToLower(cur.Display), strings.ToLower(next.Display), fmtEuro(delta), fmtEuro(int(bal)))) + } + if !p.euro.Debit(ctx.Sender, float64(delta), "mischief_escalation") { + return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.") + } + if !escalateMischiefContract(c.ID, cur.Key, next, delta, ctx.Sender) { + p.euro.Credit(ctx.Sender, float64(delta), "mischief_escalation_refund") + return p.SendReply(ctx.RoomID, ctx.EventID, + "Somebody beat you to it, or the thing already left. You've been refunded.") + } + + purse := mischiefPurse(next.Fee, next.PayoutPct) + p.announceMischiefEscalation(c, next, purse) + p.SendDM(targetID, fmt.Sprintf( + "😈 It got worse. What's coming for you is a **%s** now — somebody in town paid to upgrade it.\n"+ + "Their money's in the pot with the rest: walk away from it and you keep %s.", + strings.ToLower(next.Display), fmtEuro(purse))) + if c.BuyerID != ctx.Sender { + p.SendDM(c.BuyerID, fmt.Sprintf( + "😈 Somebody liked your idea and paid to make it worse: **%s** is getting a **%s** now.", + p.DisplayName(targetID), strings.ToLower(next.Display))) + } + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "😈 Paid. It's a **%s** now. If **%s** walks away from it they keep %s — you helped with that too.\n"+ + "_Nobody knows it was you — unless they survive._", + strings.ToLower(next.Display), p.DisplayName(targetID), fmtEuro(purse))) +} + func mischiefBuyerNote(signed bool) string { if signed { return "_Your name is on it. Everyone already knows._" @@ -653,18 +887,74 @@ func (p *AdventurePlugin) announceMischiefContract(c *mischiefContract, tier mis formatDuration(time.Until(c.WindowEndsAt)), fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct)))) } +// dmMischiefVictim is TwinBee telling the target that somebody wants them dead. +// Pure flavor — the expedition is autonomous and they cannot act on it directly — +// but it is what lets them ask the room for wards, which is the whole window. +func (p *AdventurePlugin) dmMischiefVictim(c *mischiefContract, tier mischiefTier) { + who := "Somebody in town" + if c.Signed { + who = "**" + p.DisplayName(c.BuyerID) + "**" + } + p.SendDM(c.TargetID, fmt.Sprintf( + "😈 **Word's reached me, and you're not going to like it.**\n"+ + "%s has paid to have a **%s** find you out there. It's already looking. I make it about %s.\n\n"+ + "I can't call it off, and I can't come out there. What the town *can* do is ward you — "+ + "anyone who likes you can `!mischief bless @%s` (%s each, up to %d).\n"+ + "Walk away from it and their money is yours: **%s**. And we all find out who paid.", + who, strings.ToLower(tier.Display), formatDuration(time.Until(c.WindowEndsAt)), + p.DisplayName(c.TargetID), fmtEuro(mischiefBlessingFee), mischiefBlessingCap, + fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct)))) +} + +// announceMischiefBlessing — helping is public and immediate. The blesser is +// named on the spot: there is no reason to hide having done someone a kindness, +// and the room seeing wards go up is what pulls the next one out of somebody. +func (p *AdventurePlugin) announceMischiefBlessing(c *mischiefContract, blesser id.UserID, count int) { + gr := gamesRoom() + if gr == "" { + return + } + p.SendMessage(gr, fmt.Sprintf( + "🕯️ **%s** puts a ward on **%s** — %d of %d. Whatever's coming for them will have to work harder.", + p.DisplayName(blesser), p.DisplayName(c.TargetID), count, mischiefBlessingCap)) +} + +// announceMischiefEscalation — "helping" is anonymous, exactly like the purchase +// it piles onto, and unsealed by the same survival. +func (p *AdventurePlugin) announceMischiefEscalation(c *mischiefContract, next mischiefTier, purse int) { + gr := gamesRoom() + if gr == "" { + return + } + p.SendMessage(gr, fmt.Sprintf( + "😈 **Somebody has made it worse.** What's hunting **%s** out there is a **%s** now.\n"+ + "They put their money in the pot to do it — so if **%s** walks away, the purse is up to %s.", + p.DisplayName(c.TargetID), strings.ToLower(next.Display), + p.DisplayName(c.TargetID), fmtEuro(purse))) +} + 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. + // a survival worth announcing. Whoever paid to make it worse loses theirs on + // the same terms — piling on is a purchase, and it carries the same exposure. 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**.", + "The coin came from **%s**.%s", p.DisplayName(c.TargetID), monsterName, p.DisplayName(c.TargetID), fmtEuro(purse), - p.DisplayName(c.BuyerID))) + p.DisplayName(c.BuyerID), p.mischiefEscalatorNote(c))) +} + +// mischiefEscalatorNote names whoever paid to upgrade the contract, for the +// unseal. Empty when nobody did. +func (p *AdventurePlugin) mischiefEscalatorNote(c *mischiefContract) string { + if c.EscalatedBy == "" { + return "" + } + return fmt.Sprintf(" **%s** paid to make it worse.", p.DisplayName(c.EscalatedBy)) } func (p *AdventurePlugin) announceMischiefDowned(c *mischiefContract, monsterName string) { diff --git a/internal/plugin/adventure_mischief_deliver.go b/internal/plugin/adventure_mischief_deliver.go index 01bd099..fbbc57e 100644 --- a/internal/plugin/adventure_mischief_deliver.go +++ b/internal/plugin/adventure_mischief_deliver.go @@ -121,6 +121,15 @@ func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) { if !claimMischiefForDelivery(contract.ID) { return // another tick (or another process) already has it } + // Re-read AFTER the claim, never before it. The row we were handed came from + // the due-sweep, and the window's commands (bless, escalate) keep writing to an + // open contract right up to the moment the claim closes it. A ward bought in + // that gap would be paid for and never applied; an escalation would be paid for + // and deliver the *old* tier's monster. The claim is the fence — everything the + // fight is built from has to be read on this side of it. + if fresh, err := mischiefContractByID(contract.ID); err == nil && fresh != nil { + contract = fresh + } if err := p.deliverMischief(contract, exp); err != nil { slog.Error("mischief: delivery failed", "contract", contract.ID, "target", target, "err", err) @@ -208,7 +217,8 @@ func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition) } } - narration, monsterName, survived := p.runMischiefInterrupt(target, exp, bracket, chain, ambush) + narration, monsterName, survived := p.runMischiefInterrupt( + target, exp, bracket, chain, ambush, c.BlessingCount) if survived { p.resolveMischiefSurvived(c, tier, exp, bracket, monsterName, narration) @@ -243,10 +253,17 @@ func (p *AdventurePlugin) runMischiefInterrupt( bracket ZoneDefinition, chain []DnDMonsterTemplate, ambush bool, + blessings int, ) (narration, monsterName string, survived bool) { var b strings.Builder last := chain[len(chain)-1].Name + if ward := p.applyMischiefBlessings(target, blessings); ward > 0 { + b.WriteString(fmt.Sprintf( + "_The town's wards hold — %d of them, worth %d HP of cushion._\n", blessings, ward)) + defer p.clearMischiefBlessings(target, ward) + } + 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 @@ -300,6 +317,57 @@ func (p *AdventurePlugin) runMischiefInterrupt( return b.String(), last, true } +// applyMischiefBlessings turns the room's wards into temp HP on the target's +// sheet for the duration of the delivery, and returns the cushion granted. +// +// TempHP is the well-rested mechanism (applyDnDHPScaling layers it above MaxHP +// for the fight, and persistDnDHPAfterCombat clamps back down to MaxHP after), so +// a ward is a damage sponge and nothing more — it can't leave the target at more +// HP than they started with. +// +// It ADDS to whatever TempHP is already there rather than overwriting: a target +// who long-rested at a T3 home before setting out is carrying a cushion of their +// own, and a ward bought to help them must not silently delete it. clearMischiefBlessings +// subtracts exactly what we added, which is why this returns the amount. +// +// The cushion refreshes for each link of a chain, because TempHP is a sheet field +// and applyDnDHPScaling re-reads it per fight. That only ever matters for the mob +// tier (the sole multi-fight chain), which the M0 sweep priced at 98-100% survival +// as pure theatre anyway. The tiers where a ward decides anything — elite and boss — +// are single fights, so what the room buys there is exactly one sponge. +func (p *AdventurePlugin) applyMischiefBlessings(target id.UserID, blessings int) int { + if blessings <= 0 { + return 0 + } + c, err := LoadDnDCharacter(target) + if err != nil || c == nil { + return 0 + } + ward := mischiefBlessingTempHP(c.HPMax, blessings) + if ward <= 0 { + return 0 + } + c.TempHP += ward + if err := SaveDnDCharacter(c); err != nil { + return 0 + } + return ward +} + +// clearMischiefBlessings takes the ward back off the sheet once the delivery is +// over — the blessing was bought for THIS fight, not for the rest of the run. +// Subtracts what we added, so a pre-existing well-rested cushion survives intact. +func (p *AdventurePlugin) clearMischiefBlessings(target id.UserID, ward int) { + c, err := LoadDnDCharacter(target) + if err != nil || c == nil { + return + } + if c.TempHP -= ward; c.TempHP < 0 { + c.TempHP = 0 + } + _ = SaveDnDCharacter(c) +} + // 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 @@ -370,8 +438,8 @@ func (p *AdventurePlugin) resolveMischiefSurvived( 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))) + dm.WriteString(fmt.Sprintf("It was **%s** who paid to have you killed. Do with that what you like.%s", + p.DisplayName(c.BuyerID), p.mischiefEscalatorNote(c))) for _, e := range extras { dm.WriteString("\n_" + e + "_") } @@ -380,6 +448,12 @@ func (p *AdventurePlugin) resolveMischiefSurvived( 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))) + if c.EscalatedBy != "" && c.EscalatedBy != c.BuyerID { + p.SendDM(c.EscalatedBy, fmt.Sprintf( + "🛡️ **%s** walked away from the **%s** you paid to upgrade — and your money went into their purse.\n"+ + "_The town knows you piled on. That was the deal._", + p.DisplayName(c.TargetID), c.Tier)) + } p.announceMischiefSurvived(c, monsterName, purse) emitMischiefResolvedNews(c, monsterName, mischiefOutcomeSurvived, purse) @@ -439,6 +513,13 @@ func (p *AdventurePlugin) resolveMischiefDowned( "💀 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))) + if c.EscalatedBy != "" && c.EscalatedBy != c.BuyerID { + // They stay anonymous: only a survival unseals. Their money is gone either + // way — that is what makes the survival unseal a real risk to have taken. + p.SendDM(c.EscalatedBy, fmt.Sprintf( + "💀 The **%s** you paid to upgrade found **%s** and put them down. Nobody knows it was you.", + c.Tier, p.DisplayName(c.TargetID))) + } 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 index 88d6b98..f2d0e21 100644 --- a/internal/plugin/adventure_mischief_test.go +++ b/internal/plugin/adventure_mischief_test.go @@ -377,7 +377,7 @@ func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) { bracket := mischiefBracketZone(5) chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2))) - _, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush) + _, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush, 0) if !survived { t.Fatalf("a level-5 fighter at full HP died to a grunt (%s) — the tier is mispriced", monster) } @@ -492,7 +492,12 @@ func TestMischiefDelivery_FizzlesWhenTargetWentHome(t *testing.T) { t.Fatalf("forcedExtractExpedition: %v", err) } - p.fireMischiefDeliveries(time.Now().UTC().Add(mischiefWindow + time.Minute)) + // Tomorrow at noon UTC: past the contract's window, and deliberately nowhere + // near the 06:00 briefing or the 21:00 recap. fireMischiefDeliveries refuses to + // run inside those quiet windows, so a wall-clock `now` here makes this test + // fail for two hours of every real day. + due := time.Now().UTC().AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(12 * time.Hour) + p.fireMischiefDeliveries(due) got, _ := mischiefContractByID(c.ID) if got.Status != mischiefStatusFizzled { @@ -562,3 +567,283 @@ func TestMischiefDelivery_SurvivalFloorsADroppedPartyMember(t *testing.T) { t.Error("a bought monster killed a party member — mischief maims, it never murders") } } + +// ── M2: the window (bless / escalate) ──────────────────────────────────────── + +// mischiefWindowPlugin is a plugin wired for command-level tests: a euro ledger +// and a sink, so `!mischief bless/escalate` can be driven the way a player drives +// them (the CAS races these commands lose are the whole point of M2, and they only +// exist on the command path). +func mischiefWindowPlugin(t *testing.T) (*AdventurePlugin, *captureSink) { + t.Helper() + sink := &captureSink{} + p := &AdventurePlugin{euro: NewEuroPlugin(nil)} + p.Sink = sink + return p, sink +} + +func mischiefCtx(sender id.UserID) MessageContext { + return MessageContext{Sender: sender, RoomID: id.RoomID("!room:x"), EventID: id.EventID("$e")} +} + +// The cap lives in the UPDATE, not in the read that precedes it. A room that +// piles four wards on in the same second must still land exactly three — and the +// player who lost that race must get their money back rather than pay for +// nothing. +func TestMischiefBlessing_CapIsEnforcedByTheUpdate(t *testing.T) { + newMischiefTestDB(t) + c := seedContract(t, "@buyer:x", "@target:x", "elite", false) + + for i := 1; i <= mischiefBlessingCap; i++ { + if !blessMischiefContract(c.ID) { + t.Fatalf("blessing %d/%d was rejected", i, mischiefBlessingCap) + } + } + if blessMischiefContract(c.ID) { + t.Errorf("a %dth blessing landed — the cap is not enforced", mischiefBlessingCap+1) + } + got, _ := mischiefContractByID(c.ID) + if got.BlessingCount != mischiefBlessingCap { + t.Errorf("blessing_count = %d, want %d", got.BlessingCount, mischiefBlessingCap) + } + // And nobody may ward a contract that has already been claimed for delivery: + // the monster is in the room with them. + c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false) + if !claimMischiefForDelivery(c2.ID) { + t.Fatal("claim should win") + } + if blessMischiefContract(c2.ID) { + t.Error("warded a contract mid-delivery — the fight had already started") + } +} + +// A blessing buys HP, never euros: the fee is a sink (community pot), and the +// purse is untouched by it. Otherwise a target's friend could ward them as a way +// of routing money into their pocket on the way out. +func TestMischiefBlessing_FeeIsASinkNotAPurseTopUp(t *testing.T) { + newMischiefTestDB(t) + p, _ := mischiefWindowPlugin(t) + target := id.UserID("@warded:x") + friend := id.UserID("@friend:x") + p.euro.Credit(friend, 500, "test") + + c := seedContract(t, "@buyer:x", target, "elite", false) + feeBefore := c.Fee + + if err := p.mischiefBlessCmd(mischiefCtx(friend), []string{string(target)}); err != nil { + t.Fatalf("bless: %v", err) + } + + got, _ := mischiefContractByID(c.ID) + if got.BlessingCount != 1 { + t.Fatalf("blessing_count = %d, want 1", got.BlessingCount) + } + if got.Fee != feeBefore { + t.Errorf("a blessing moved the payout basis to %d (was %d) — wards must not pay the target", + got.Fee, feeBefore) + } + if bal := p.euro.GetBalance(friend); bal != 500-mischiefBlessingFee { + t.Errorf("blesser balance %.0f, want %d", bal, 500-mischiefBlessingFee) + } + if pot := communityPotBalance(); pot != mischiefBlessingFee { + t.Errorf("pot holds %d, want the %d blessing fee", pot, mischiefBlessingFee) + } + // Broke, so the second ward can't be bought — and must cost nothing. + pauper := id.UserID("@pauper:x") + if err := p.mischiefBlessCmd(mischiefCtx(pauper), []string{string(target)}); err != nil { + t.Fatalf("bless: %v", err) + } + if bal := p.euro.GetBalance(pauper); bal != 0 { + t.Errorf("a failed blessing moved the pauper's balance to %.0f", bal) + } + if got, _ := mischiefContractByID(c.ID); got.BlessingCount != 1 { + t.Errorf("a blessing nobody could pay for still landed (count %d)", got.BlessingCount) + } +} + +// Escalation moves the tier, the payout basis and the outlay together — which is +// what keeps the anti-collusion invariant (purse < what was spent) true after the +// room has piled on. It also happens exactly once. +func TestMischiefEscalation_RaisesBasisAndOutlayTogether(t *testing.T) { + newMischiefTestDB(t) + p, _ := mischiefWindowPlugin(t) + target := id.UserID("@hunted:x") + piler := id.UserID("@piler:x") + rival := id.UserID("@rival:x") + p.euro.Credit(piler, 5000, "test") + p.euro.Credit(rival, 5000, "test") + + c := seedContract(t, "@buyer:x", target, "elite", true) // signed: paid > fee + elite, _ := mischiefTierByKey("elite") + boss, _ := mischiefTierByKey("boss") + delta := boss.Fee - elite.Fee + + if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil { + t.Fatalf("escalate: %v", err) + } + + got, _ := mischiefContractByID(c.ID) + if got.Tier != "boss" || got.Fee != boss.Fee { + t.Fatalf("contract is %s/fee %d, want boss/%d", got.Tier, got.Fee, boss.Fee) + } + if got.Paid != c.Paid+delta { + t.Errorf("paid = %d, want %d (%d + the %d delta)", got.Paid, c.Paid+delta, c.Paid, delta) + } + if got.EscalatedBy != piler { + t.Errorf("escalated_by = %q, want %s — the unseal has nobody to name", got.EscalatedBy, piler) + } + if bal := p.euro.GetBalance(piler); bal != float64(5000-delta) { + t.Errorf("escalator paid %.0f, want the %d delta", 5000-bal, delta) + } + // The invariant, after the escalation: the purse is still strictly less than + // the money that went in. + if purse := mischiefPurse(got.Fee, boss.PayoutPct); purse >= got.Paid { + t.Errorf("escalated purse %d >= outlay %d — collusion now beats !baltransfer", purse, got.Paid) + } + + // One step, once — whoever else was reaching for it is refunded. + if err := p.mischiefEscalateCmd(mischiefCtx(rival), []string{string(target)}); err != nil { + t.Fatalf("second escalate: %v", err) + } + if bal := p.euro.GetBalance(rival); bal != 5000 { + t.Errorf("the losing escalator is out %.0f — a rejected escalation must cost nothing", 5000-bal) + } + after, _ := mischiefContractByID(c.ID) + if after.EscalationCount != 1 || after.Paid != got.Paid { + t.Errorf("contract escalated twice (count %d, paid %d)", after.EscalationCount, after.Paid) + } +} + +// Boss tier is the "end their expedition" button, and the weekly cap on it is the +// target's protection. An escalation into boss is still a boss landing on them, so +// it answers to the same cap — otherwise the cap is bought around for the price of +// an elite plus the delta. +func TestMischiefEscalation_IntoBossRespectsTheWeeklyCap(t *testing.T) { + newMischiefTestDB(t) + p, _ := mischiefWindowPlugin(t) + target := id.UserID("@bossed:x") + piler := id.UserID("@piler:x") + p.euro.Credit(piler, 5000, "test") + + // They already took a boss this week (delivered, so it counts). + spent := seedContract(t, "@buyer:x", target, "boss", false) + resolveMischiefContract(spent.ID, mischiefStatusDelivered, mischiefOutcomeSurvived) + + c := seedContract(t, "@buyer2:x", target, "elite", false) + if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil { + t.Fatalf("escalate: %v", err) + } + got, _ := mischiefContractByID(c.ID) + if got.Tier != "elite" { + t.Errorf("escalated to %s — the weekly boss cap was bought around", got.Tier) + } + if bal := p.euro.GetBalance(piler); bal != 5000 { + t.Errorf("a refused escalation charged %.0f", 5000-bal) + } +} + +// Boss is the top of the ladder, and a target may not raise the price on their +// own head. +func TestMischiefEscalation_RefusesBossAndSelfService(t *testing.T) { + newMischiefTestDB(t) + p, _ := mischiefWindowPlugin(t) + target := id.UserID("@top:x") + piler := id.UserID("@piler:x") + p.euro.Credit(piler, 5000, "test") + p.euro.Credit(target, 5000, "test") + + c := seedContract(t, "@buyer:x", target, "boss", false) + if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil { + t.Fatalf("escalate: %v", err) + } + if got, _ := mischiefContractByID(c.ID); got.EscalationCount != 0 { + t.Error("something got escalated past boss tier") + } + if bal := p.euro.GetBalance(piler); bal != 5000 { + t.Errorf("charged %.0f for an impossible escalation", 5000-bal) + } + + newMischiefTestDB(t) + c2 := seedContract(t, "@buyer:x", target, "grunt", false) + if err := p.mischiefEscalateCmd(mischiefCtx(target), []string{string(target)}); err != nil { + t.Fatalf("escalate: %v", err) + } + if got, _ := mischiefContractByID(c2.ID); got.EscalationCount != 0 { + t.Error("the target escalated their own contract") + } +} + +// The window keeps writing to an open contract right up to the claim, so the +// delivery must build the fight from the row as it stands AFTER the claim — not +// from the copy the due-sweep handed it. A tier bought at the last second (an +// escalation) is the visible half of this: read stale, the buyer pays for a boss +// and the target fights a grunt. +func TestMischiefDelivery_ReadsTheContractAfterTheClaim(t *testing.T) { + newMischiefTestDB(t) + p, _ := mischiefWindowPlugin(t) + uid := id.UserID("@lastsecond:x") + seedMischiefTarget(t, uid, 12, 400) + + c := seedContract(t, "@buyer:x", uid, "grunt", false) + // Somebody escalates in the gap between the due-sweep and the claim. `c` is now + // the stale copy the ticker is still holding. + elite, _ := mischiefTierByKey("elite") + if !escalateMischiefContract(c.ID, "grunt", elite, elite.Fee-c.Fee, "@piler:x") { + t.Fatal("escalation should have taken") + } + + before := p.euro.GetBalance(uid) + p.fireOneMischiefDelivery(c) + + got, _ := mischiefContractByID(c.ID) + if got.Outcome != mischiefOutcomeSurvived { + t.Fatalf("target lost; this test needs a survival to read the purse (outcome %s)", got.Outcome) + } + want := mischiefPurse(elite.Fee, elite.PayoutPct) + if gained := int(p.euro.GetBalance(uid) - before); gained != want { + t.Errorf("survivor was paid %d, want the escalated %d purse — "+ + "the delivery ran off the pre-claim copy of the contract", gained, want) + } +} + +// The ward is a cushion for THIS fight and nothing else: it goes on the sheet as +// temp HP for the delivery and comes off after — without eating the well-rested +// cushion the target may already have been carrying out of the door. +func TestMischiefBlessing_CushionsTheFightThenClearsItself(t *testing.T) { + newMischiefTestDB(t) + p, _ := mischiefWindowPlugin(t) + uid := id.UserID("@blessed:x") + exp := seedMischiefTarget(t, uid, 12, 400) + + // They long-rested at home before setting out. + dc, _ := LoadDnDCharacter(uid) + dc.TempHP = 32 + if err := SaveDnDCharacter(dc); err != nil { + t.Fatal(err) + } + + c := seedContract(t, "@buyer:x", uid, "grunt", false) + for i := 0; i < mischiefBlessingCap; i++ { + if !blessMischiefContract(c.ID) { + t.Fatal("seed blessing rejected") + } + } + c.BlessingCount = mischiefBlessingCap + + want := mischiefBlessingTempHP(400, mischiefBlessingCap) // 3 × 10% of 400 + if want != 120 { + t.Fatalf("ward is %d HP, want 120 — the fight is being cushioned by something else", want) + } + if !claimMischiefForDelivery(c.ID) { + t.Fatal("claim should win") + } + if err := p.deliverMischief(c, exp); err != nil { + t.Fatalf("deliverMischief: %v", err) + } + + after, _ := LoadDnDCharacter(uid) + if after.TempHP != 32 { + t.Errorf("temp HP is %d after the delivery, want the 32 they rested for — "+ + "the ward must not linger, and must not eat their own cushion", after.TempHP) + } +}