diff --git a/internal/plugin/adventure_mischief.go b/internal/plugin/adventure_mischief.go index f08a535..eff75ac 100644 --- a/internal/plugin/adventure_mischief.go +++ b/internal/plugin/adventure_mischief.go @@ -75,10 +75,16 @@ 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 + // A ward is priced against the contract it is defending: a floor, or a slice of + // the fee, whichever is more. Warding someone has to stay an impulse at the + // cheap tiers (or nobody bothers and the window is just a countdown), but the + // M1-close-out sweep measured three wards buying roughly +40pp of survival — + // fighter L12 vs a boss at 70% HP goes 48% → 90%. At a flat fee that let the + // room halve a €1,200 boss contract for €75, which makes the tier the whole + // economy rests on feel like money thrown away. Saving someone from a boss + // should cost the town real money: €120 a ward, €360 to cover them completely. + mischiefBlessingFeeMin = 25 + mischiefBlessingFeePct = 0.10 // 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 @@ -149,6 +155,17 @@ func mischiefNextTier(key string) (mischiefTier, bool) { return mischiefTier{}, false } +// mischiefBlessingFee is what one ward costs against a contract of this fee. It +// reads the contract's CURRENT basis, so an escalation raises the price of +// protecting its target too — the room's counterplay tracks the threat. +func mischiefBlessingFee(contractFee int) int { + fee := int(math.Round(float64(contractFee) * mischiefBlessingFeePct)) + if fee < mischiefBlessingFeeMin { + return mischiefBlessingFeeMin + } + return fee +} + // 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. @@ -580,17 +597,18 @@ func (p *AdventurePlugin) mischiefBoard() string { 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", + b.WriteString(fmt.Sprintf("· **%s** — %s (signed: %s) — survivor keeps %s · ward %s\n _%s_\n", t.Display, fmtEuro(t.Fee), fmtEuro(mischiefSignedFee(t.Fee)), - fmtEuro(mischiefPurse(t.Fee, t.PayoutPct)), t.Blurb)) + fmtEuro(mischiefPurse(t.Fee, t.PayoutPct)), fmtEuro(mischiefBlessingFee(t.Fee)), 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 bless @user` — a ward for the fight, up to %d of them. Help them. "+ + "The worse the thing coming, the more a ward costs.\n"+ "· `!mischief escalate @user` — pay the difference, send something worse. \"Help\" them. "+ "It raises their purse too, if they live.\n", - fmtEuro(mischiefBlessingFee), mischiefBlessingCap)) + 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() @@ -764,22 +782,26 @@ func (p *AdventurePlugin) mischiefBlessCmd(ctx MessageContext, fields []string) "**%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 { + // Priced off the contract's current basis: warding someone against a boss costs + // what a boss is worth, and an escalation raises the price of saving them. + fee := mischiefBlessingFee(c.Fee) + if bal := p.euro.GetBalance(ctx.Sender); int(bal) < fee { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( - "A ward costs %s — you have %s.", fmtEuro(mischiefBlessingFee), fmtEuro(int(bal)))) + "A ward against a %s costs %s — you have %s.", + strings.ToLower(c.Tier), fmtEuro(fee), fmtEuro(int(bal)))) } - if !p.euro.Debit(ctx.Sender, float64(mischiefBlessingFee), "mischief_blessing") { + if !p.euro.Debit(ctx.Sender, float64(fee), "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") + p.euro.Credit(ctx.Sender, float64(fee), "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) + communityPotAdd(fee) + trackTaxPaid(ctx.Sender, fee) p.announceMischiefBlessing(c, ctx.Sender, c.BlessingCount+1) p.SendDM(targetID, fmt.Sprintf( @@ -902,7 +924,7 @@ func (p *AdventurePlugin) dmMischiefVictim(c *mischiefContract, tier mischiefTie "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, + p.DisplayName(c.TargetID), fmtEuro(mischiefBlessingFee(c.Fee)), mischiefBlessingCap, fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct)))) } diff --git a/internal/plugin/adventure_mischief_test.go b/internal/plugin/adventure_mischief_test.go index f2d0e21..055f59e 100644 --- a/internal/plugin/adventure_mischief_test.go +++ b/internal/plugin/adventure_mischief_test.go @@ -586,6 +586,36 @@ func mischiefCtx(sender id.UserID) MessageContext { return MessageContext{Sender: sender, RoomID: id.RoomID("!room:x"), EventID: id.EventID("$e")} } +// A ward is priced against the thing it defends against. The M1-close-out sweep +// measured three wards buying ~+40pp of survival, which at a flat fee let the room +// halve a €1,200 boss contract for €75 — the tier the whole economy rests on, +// bought off for pocket change. Cheap at the impulse tiers, real money at the top. +func TestMischiefBlessingFee_ScalesWithTheContract(t *testing.T) { + want := map[string]int{"grunt": 25, "mob": 25, "elite": 35, "boss": 120} + for _, tier := range mischiefTiers { + if got := mischiefBlessingFee(tier.Fee); got != want[tier.Key] { + t.Errorf("%s ward = %d, want %d", tier.Key, got, want[tier.Key]) + } + // Where a ward is a rational purchase — the tiers the sweep says can + // actually put someone on the floor — covering a target must cost less than + // the contract does. Otherwise the room's counterplay is priced out and the + // window is dead. + // + // Grunt and mob are deliberately exempt: the €25 floor is above-market + // there (3 wards cost more than a €40 grunt), and that is fine, because + // nothing in the sweep needs warding against theatre — those tiers sit at + // 100% survival unwounded. The floor is there to keep a ward an impulse + // buy, not to make it a sensible one against a goblin. + if tier.Key != "elite" && tier.Key != "boss" { + continue + } + if full := mischiefBlessingFee(tier.Fee) * mischiefBlessingCap; full >= tier.Fee { + t.Errorf("%s: %d wards cost %d, which is more than the %d contract — "+ + "the room can't afford to save anyone", tier.Key, mischiefBlessingCap, full, tier.Fee) + } + } +} + // 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 @@ -629,6 +659,7 @@ func TestMischiefBlessing_FeeIsASinkNotAPurseTopUp(t *testing.T) { c := seedContract(t, "@buyer:x", target, "elite", false) feeBefore := c.Fee + ward := mischiefBlessingFee(c.Fee) // 10% of the €350 elite fee if err := p.mischiefBlessCmd(mischiefCtx(friend), []string{string(target)}); err != nil { t.Fatalf("bless: %v", err) @@ -642,11 +673,11 @@ func TestMischiefBlessing_FeeIsASinkNotAPurseTopUp(t *testing.T) { 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 bal := p.euro.GetBalance(friend); bal != float64(500-ward) { + t.Errorf("blesser balance %.0f, want %d", bal, 500-ward) } - if pot := communityPotBalance(); pot != mischiefBlessingFee { - t.Errorf("pot holds %d, want the %d blessing fee", pot, mischiefBlessingFee) + if pot := communityPotBalance(); pot != ward { + t.Errorf("pot holds %d, want the %d ward fee", pot, ward) } // Broke, so the second ward can't be bought — and must cost nothing. pauper := id.UserID("@pauper:x") diff --git a/internal/plugin/mischief_delivery_sweep_test.go b/internal/plugin/mischief_delivery_sweep_test.go new file mode 100644 index 0000000..2bbc979 --- /dev/null +++ b/internal/plugin/mischief_delivery_sweep_test.go @@ -0,0 +1,148 @@ +package plugin + +// M1 close-out sweep for gogobee_mischief_plan.md — survival per tier through the +// REAL delivery path (runMischiefInterrupt → runZoneCombatRoster), not through +// SimulateCombat. +// +// The M0 sweep that priced the fee table measured a full-HP build in a single +// chain. Two things it could not see, and both of them are why this exists: +// +// - A real target is WOUNDED. The monster arrives mid-run, after the dungeon has +// already taken a bite out of them. The entryHP arm is the control-vs-treatment +// axis: 100% reproduces M0 through the new path (so a divergence there is a +// path bug, not a difficulty finding), 70% and 40% are what a live delivery +// actually lands on. +// - The M2 ward. `blessings` drives the same temp-HP cushion the room buys, so +// we can price what €75 of charity is actually worth against an elite. +// +// Run (heavy — see the plan's sim rules, n≥750 per cell for a real signal): +// +// MISCHIEF_SWEEP=1 go test ./internal/plugin -run TestMischiefDeliverySweep -v -timeout 4h + +import ( + "fmt" + "math/rand/v2" + "os" + "strconv" + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// mischiefDeliveryTrial seeds a real adventurer on a real expedition at the given +// fraction of their max HP, sends them the tier's monster through the production +// delivery path, and reports whether they were still standing. +// +// Survival is read the way the delivery reads it (HP > 0), not as "won every +// fight" — an engine timeout with HP left is a target who held the thing off. +func mischiefDeliveryTrial( + t *testing.T, p *AdventurePlugin, prof classBalanceProfile, + tierKey string, entryHPPct float64, blessings int, seq int, rng *rand.Rand, +) (survived bool, endHPPct float64) { + t.Helper() + uid := id.UserID(fmt.Sprintf("@sweep%d:sim", seq)) + + c := buildHarnessCharacter(prof) + c.UserID = uid + c.HPCurrent = int(float64(c.HPMax) * entryHPPct) + if c.HPCurrent < 1 { + c.HPCurrent = 1 + } + if err := createAdvCharacter(uid, "sweep"+strconv.Itoa(seq)); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatalf("SaveDnDCharacter: %v", err) + } + + run, err := startZoneRun(uid, ZoneGoblinWarrens, prof.Level, rng) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + expID := "exp-sweep-" + strconv.Itoa(seq) + 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', ?, 0)`, + expID, 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) + } + + bracket := mischiefBracketZone(prof.Level) + chain, ambush := mischiefMonsters(tierKey, bracket, rng) + + _, _, survived = p.runMischiefInterrupt(uid, exp, bracket, chain, ambush, blessings) + hp, max := dndHPSnapshot(uid) + if max <= 0 { + max = 1 + } + return survived, float64(hp) / float64(max) +} + +func TestMischiefDeliverySweep(t *testing.T) { + if os.Getenv("MISCHIEF_SWEEP") == "" { + t.Skip("set MISCHIEF_SWEEP=1 to run") + } + trials := 250 + if n := os.Getenv("MISCHIEF_TRIALS"); n != "" { + if v, err := strconv.Atoi(n); err == nil && v > 0 { + trials = v + } + } + newMischiefTestDB(t) + p := &AdventurePlugin{euro: NewEuroPlugin(nil)} + p.Sink = &captureSink{} + + rng := rand.New(rand.NewPCG(20260713, 0x64656C6976657279)) + profiles := []classBalanceProfile{ + {Class: ClassPaladin, Level: 3}, + {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}, + } + // entryHP 1.0 is the control arm: it should reproduce the M0 table through the + // real path. The wounded arms are the finding. + entryHPs := []float64{1.0, 0.7, 0.4} + + seq := 0 + fmt.Printf("\n%-26s %-6s %-7s %-6s %8s %10s\n", + "profile", "tier", "entryHP", "wards", "survive%", "avgEndHP%") + for _, prof := range profiles { + for _, tier := range mischiefTiers { + for _, entry := range entryHPs { + // The ward arm only where a ward could decide anything: the tiers the + // M0 sweep did not already call theatre. + wardArms := []int{0} + if tier.Key == "elite" || tier.Key == "boss" { + wardArms = []int{0, mischiefBlessingCap} + } + for _, wards := range wardArms { + wins, hpSum := 0, 0.0 + for i := 0; i < trials; i++ { + seq++ + ok, hpPct := mischiefDeliveryTrial(t, p, prof, tier.Key, entry, wards, seq, rng) + if ok { + wins++ + hpSum += hpPct + } + } + avgHP := 0.0 + if wins > 0 { + avgHP = hpSum / float64(wins) * 100 + } + fmt.Printf("%-26s %-6s %6.0f%% %6d %7.1f%% %9.1f%%\n", + fmt.Sprintf("%s L%d %s", prof.Class, prof.Level, prof.Subclass), + tier.Key, entry*100, wards, float64(wins)/float64(trials)*100, avgHP) + } + } + } + } +}