mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
mischief: the town gets a say before the monster lands
M2 — the window between a contract being placed and the monster finding its target. Anyone can ward the victim (!mischief bless, €25, three max, +10% MaxHP of temp HP each) or pay the tier delta to send something worse (!mischief escalate, one step, boss is the ceiling). TwinBee DMs the victim on placement so they can go and ask for wards. Escalation money joins the payout basis, so piling on raises the purse the target walks away with if they live — and an escalator is unsealed on a survival exactly like the buyer. Cruelty and generosity are the same button. Both commands are CAS-then-refund: the ward cap and the one-step limit live in the UPDATE's WHERE clause, because a scramble is precisely when four people press the button in the same second. Also fixes a real bug this exposed: the delivery built its fight from the copy of the contract the due-sweep handed it, but the window keeps writing to an open row right up to the claim. A last-second escalation was paid for and then delivered the old tier's monster. The claim is the fence; read on that side of it.
This commit is contained in:
@@ -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 = <from> 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 <tier> @user` · `!mischief status`")
|
||||
"Unknown option. `!mischief` for the board · `!mischief send <tier> @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 <tier> @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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user