mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
merge: Mischief Makers M1 — paid monster hits on live expeditions
This commit is contained in:
@@ -1734,6 +1734,44 @@ CREATE TABLE IF NOT EXISTS community_pot (
|
|||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Mischief Makers: a paid monster hit on a player who is out on an expedition.
|
||||||
|
-- The buyer's euros are debited at placement (paid); fee is the payout basis the
|
||||||
|
-- target's survival purse is a percentage of — the sign surcharge is a pure sink
|
||||||
|
-- and never inflates the purse, which is what keeps a payout strictly below what
|
||||||
|
-- the buyer spent (collusion loses to !baltransfer, which is free).
|
||||||
|
--
|
||||||
|
-- Lifecycle: open → delivering → delivered (outcome survived|downed) | fizzled.
|
||||||
|
-- Every transition is a conditional UPDATE, so a double-fire of the ticker or a
|
||||||
|
-- restart mid-delivery can never pay twice. No bootstrap — absent row == no
|
||||||
|
-- contract in flight.
|
||||||
|
CREATE TABLE IF NOT EXISTS mischief_contracts (
|
||||||
|
contract_id TEXT PRIMARY KEY,
|
||||||
|
buyer_id TEXT NOT NULL,
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
fee INTEGER NOT NULL,
|
||||||
|
paid INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
signed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
escalation_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blessing_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL DEFAULT 'matrix',
|
||||||
|
outcome TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
window_ends_at DATETIME NOT NULL,
|
||||||
|
resolved_at DATETIME
|
||||||
|
);
|
||||||
|
-- One live contract per target, enforced by the database rather than by a
|
||||||
|
-- read-then-write check. The placement path holds only the BUYER's lock, so two
|
||||||
|
-- different buyers racing to hit the same person would both pass an in-code
|
||||||
|
-- "is one already live?" test. This partial unique index is what actually stops
|
||||||
|
-- the second insert; the loser is refunded.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_mischief_one_live_per_target
|
||||||
|
ON mischief_contracts(target_id) WHERE status IN ('open', 'delivering');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_buyer ON mischief_contracts(buyer_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_at);
|
||||||
|
|
||||||
-- Babysitting Service
|
-- Babysitting Service
|
||||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
|||||||
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
|
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
|
||||||
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
|
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
|
||||||
{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
|
{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
|
||||||
|
{Name: "mischief", Description: "Pay to send a monster after an adventurer who's out on an expedition", Usage: "!mischief send <tier> @user", Category: "Games"},
|
||||||
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
|
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
|
||||||
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
|
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
|
||||||
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
||||||
@@ -288,6 +289,7 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
go p.peteRosterTicker()
|
go p.peteRosterTicker()
|
||||||
go p.expeditionExtractionSweepTicker()
|
go p.expeditionExtractionSweepTicker()
|
||||||
go p.expeditionBoredomTicker()
|
go p.expeditionBoredomTicker()
|
||||||
|
go p.mischiefTicker()
|
||||||
|
|
||||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||||
p.arenaCleanupStaleRuns()
|
p.arenaCleanupStaleRuns()
|
||||||
@@ -362,6 +364,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
if p.IsCommand(ctx.Body, "duel") {
|
if p.IsCommand(ctx.Body, "duel") {
|
||||||
return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
|
return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
|
||||||
}
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "mischief") {
|
||||||
|
return p.handleMischiefCmd(ctx, p.GetArgs(ctx.Body, "mischief"))
|
||||||
|
}
|
||||||
if p.IsCommand(ctx.Body, "arm") {
|
if p.IsCommand(ctx.Body, "arm") {
|
||||||
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
|
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
|
||||||
}
|
}
|
||||||
@@ -543,6 +548,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
|||||||
return p.handleRivalsCmd(ctx)
|
return p.handleRivalsCmd(ctx)
|
||||||
case lower == "duel" || strings.HasPrefix(lower, "duel "):
|
case lower == "duel" || strings.HasPrefix(lower, "duel "):
|
||||||
return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
|
return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
|
||||||
|
case lower == "mischief" || strings.HasPrefix(lower, "mischief "):
|
||||||
|
return p.handleMischiefCmd(ctx, strings.TrimSpace(args[len("mischief"):]))
|
||||||
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
|
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
|
||||||
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
|
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
|
||||||
case lower == "blacksmith" || lower == "repair":
|
case lower == "blacksmith" || lower == "repair":
|
||||||
|
|||||||
773
internal/plugin/adventure_mischief.go
Normal file
773
internal/plugin/adventure_mischief.go
Normal file
@@ -0,0 +1,773 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// Mischief Makers (M1) — paid monster hits on live expeditions.
|
||||||
|
//
|
||||||
|
// A buyer spends euros to send a monster at an adventurer who is out in a
|
||||||
|
// dungeon right now. The games room hears the contract is out; a window later
|
||||||
|
// the monster is delivered mid-run. Surviving it pays the target and unseals
|
||||||
|
// the buyer in Pete's bulletin. See gogobee_mischief_plan.md.
|
||||||
|
//
|
||||||
|
// Three properties the design leans on, all of them enforced here rather than
|
||||||
|
// trusted:
|
||||||
|
//
|
||||||
|
// - The monster is priced against the TARGET, not the dungeon they happen to
|
||||||
|
// be standing in. It comes from the zone pool of the target's own level
|
||||||
|
// bracket (mischiefBracketZones), which is the corpus the difficulty pass is
|
||||||
|
// calibrated on. The arena ladder is deliberately NOT the source: its
|
||||||
|
// BaseLethality is a legacy flat death chance and arena T5 one-shots a level
|
||||||
|
// 20, which would collapse every tier into an execution.
|
||||||
|
//
|
||||||
|
// - The payout basis is the BASE fee, never what the buyer actually paid. The
|
||||||
|
// +25% sign surcharge is a pure sink. With the per-tier percentages below
|
||||||
|
// capped at 75%, a survival purse is always strictly less than the buyer's
|
||||||
|
// outlay — so two players colluding to move money this way lose to
|
||||||
|
// `!baltransfer`, which is free. That cap is the whole anti-collusion story;
|
||||||
|
// no danger multiplier is needed.
|
||||||
|
//
|
||||||
|
// - Mischief maims, it does not murder. The delivery close-out floors every
|
||||||
|
// seat at 1 HP and force-extracts the expedition. A purchased attack that
|
||||||
|
// lands while the victim is asleep must not be able to delete their
|
||||||
|
// character — see mischiefResolveDowned.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// mischiefWindow — how long the games room has to react before the monster
|
||||||
|
// is delivered. Long enough for a bless/escalate scramble (M2), short
|
||||||
|
// enough that a contract resolves inside one sitting.
|
||||||
|
mischiefWindow = 60 * time.Minute
|
||||||
|
|
||||||
|
// mischiefMinLevel — a level 1-2 adventurer is nobody's idea of a fair
|
||||||
|
// target, and the bracket-1 pool is the only thing we could send anyway.
|
||||||
|
mischiefMinLevel = 3
|
||||||
|
|
||||||
|
// mischiefTargetCooldown — a target gets a breather after one resolves, so
|
||||||
|
// a buyer with money can't chain-maim someone out of the game.
|
||||||
|
mischiefTargetCooldown = 24 * time.Hour
|
||||||
|
|
||||||
|
// mischiefBuyerDailyCap — contracts one buyer may place per rolling day.
|
||||||
|
mischiefBuyerDailyCap = 2
|
||||||
|
|
||||||
|
// mischiefBossPerTargetWindow — boss tier is "end this expedition" against a
|
||||||
|
// caster (0-14% survival in the M0 sweep). One per target per week, on top of
|
||||||
|
// the cooldown and the buyer cap.
|
||||||
|
mischiefBossPerTargetWindow = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// mischiefSignSurcharge — the buyer pays this much on top to put their name
|
||||||
|
// on the contract from the start. Pure sink; see the payout-basis note above.
|
||||||
|
mischiefSignSurcharge = 0.25
|
||||||
|
|
||||||
|
// Fizzle: the expedition ended before the monster could find them.
|
||||||
|
mischiefFizzleRefund = 0.90
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contract statuses. `delivering` is the claimed-but-unresolved state a CAS
|
||||||
|
// moves through, so a ticker double-fire finds nothing left to claim.
|
||||||
|
const (
|
||||||
|
mischiefStatusOpen = "open"
|
||||||
|
mischiefStatusDelivering = "delivering"
|
||||||
|
mischiefStatusDelivered = "delivered"
|
||||||
|
mischiefStatusFizzled = "fizzled"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mischiefOutcomeSurvived = "survived"
|
||||||
|
mischiefOutcomeDowned = "downed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Tiers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// mischiefTier is one rung of the grunt→boss ladder. Fee and PayoutPct come
|
||||||
|
// from the M0 pricing sweep (n=2000/cell against the real combat engine) read
|
||||||
|
// against the live prod economy — see the tier table in the plan doc.
|
||||||
|
type mischiefTier struct {
|
||||||
|
Key string
|
||||||
|
Display string
|
||||||
|
Fee int // base fee; also the payout basis
|
||||||
|
PayoutPct float64 // survival purse as a fraction of Fee. Capped at 0.75.
|
||||||
|
Blurb string
|
||||||
|
}
|
||||||
|
|
||||||
|
var mischiefTiers = []mischiefTier{
|
||||||
|
{Key: "grunt", Display: "Grunt", Fee: 40, PayoutPct: 0.40,
|
||||||
|
Blurb: "One of the local nasties. Mostly theatre — it'll cost them blood, not the run."},
|
||||||
|
{Key: "mob", Display: "Mob", Fee: 100, PayoutPct: 0.50,
|
||||||
|
Blurb: "Three of them, back to back. Attrition, and a real dent in the supplies."},
|
||||||
|
{Key: "elite", Display: "Elite", Fee: 350, PayoutPct: 0.65,
|
||||||
|
Blurb: "Something that hunts on purpose, and it opens with a free swing. A coin-flip at their level."},
|
||||||
|
{Key: "boss", Display: "Boss", Fee: 1200, PayoutPct: 0.75,
|
||||||
|
Blurb: "The worst thing in their bracket, with the drop on them. This ends expeditions."},
|
||||||
|
}
|
||||||
|
|
||||||
|
func mischiefTierByKey(key string) (mischiefTier, bool) {
|
||||||
|
for _, t := range mischiefTiers {
|
||||||
|
if t.Key == strings.ToLower(strings.TrimSpace(key)) {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mischiefTier{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefSignedFee is what a buyer pays to put their name on the contract up
|
||||||
|
// front. The extra is a sink — mischiefPurse never sees it.
|
||||||
|
func mischiefSignedFee(fee int) int {
|
||||||
|
return int(math.Round(float64(fee) * (1 + mischiefSignSurcharge)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefPurse is the target's cut for surviving: a percentage of the BASE fee
|
||||||
|
// (which escalations raise, and the sign surcharge does not). Hard-capped at 75%
|
||||||
|
// so no combination of tier + escalation can pay out more than three-quarters of
|
||||||
|
// what was spent.
|
||||||
|
func mischiefPurse(fee int, pct float64) int {
|
||||||
|
if pct > 0.75 {
|
||||||
|
pct = 0.75
|
||||||
|
}
|
||||||
|
return int(math.Round(float64(fee) * pct))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Monster selection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// mischiefBracketZones — the zone whose enemy pool a target of each level
|
||||||
|
// bracket draws its attacker from. The target's *current* dungeon is not used:
|
||||||
|
// a level 20 slumming in a tier 1 zone should still be sent something that can
|
||||||
|
// find them, and a level 4 who wandered into a tier 4 zone should not be sent
|
||||||
|
// its boss.
|
||||||
|
var mischiefBracketZones = map[int]ZoneID{
|
||||||
|
1: ZoneGoblinWarrens,
|
||||||
|
2: ZoneSunkenTemple,
|
||||||
|
3: ZoneManorBlackspire,
|
||||||
|
4: ZoneUnderdark,
|
||||||
|
5: ZoneDragonsLair,
|
||||||
|
}
|
||||||
|
|
||||||
|
func mischiefBracketForLevel(level int) int {
|
||||||
|
switch {
|
||||||
|
case level <= 3:
|
||||||
|
return 1
|
||||||
|
case level <= 7:
|
||||||
|
return 2
|
||||||
|
case level <= 12:
|
||||||
|
return 3
|
||||||
|
case level <= 17:
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefBracketZone resolves the zone a target's attacker is drawn from.
|
||||||
|
func mischiefBracketZone(level int) ZoneDefinition {
|
||||||
|
zone, _ := getZone(mischiefBracketZones[mischiefBracketForLevel(level)])
|
||||||
|
return zone
|
||||||
|
}
|
||||||
|
|
||||||
|
func mischiefPickEnemy(zone ZoneDefinition, elite bool, rng *rand.Rand) DnDMonsterTemplate {
|
||||||
|
var pool []ZoneEnemy
|
||||||
|
for _, e := range zone.Enemies {
|
||||||
|
if e.IsElite == elite {
|
||||||
|
pool = append(pool, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pool) == 0 {
|
||||||
|
pool = zone.Enemies
|
||||||
|
}
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return DnDMonsterTemplate{}
|
||||||
|
}
|
||||||
|
return dndBestiary[pool[rng.IntN(len(pool))].BestiaryID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefMonsters returns the fight chain a tier sends, and whether the first
|
||||||
|
// monster gets the ambush (the attacker's privilege — it was lying in wait).
|
||||||
|
// This IS the shape the M0 sweep priced; changing it invalidates the fee table.
|
||||||
|
//
|
||||||
|
// grunt = one non-elite zone enemy, no ambush
|
||||||
|
// mob = three non-elite zone enemies back to back (HP carries), no ambush
|
||||||
|
// elite = one elite zone enemy, ambush
|
||||||
|
// boss = the zone boss, ambush
|
||||||
|
func mischiefMonsters(tierKey string, zone ZoneDefinition, rng *rand.Rand) (chain []DnDMonsterTemplate, ambush bool) {
|
||||||
|
switch tierKey {
|
||||||
|
case "grunt":
|
||||||
|
return []DnDMonsterTemplate{mischiefPickEnemy(zone, false, rng)}, false
|
||||||
|
case "mob":
|
||||||
|
return []DnDMonsterTemplate{
|
||||||
|
mischiefPickEnemy(zone, false, rng),
|
||||||
|
mischiefPickEnemy(zone, false, rng),
|
||||||
|
mischiefPickEnemy(zone, false, rng),
|
||||||
|
}, false
|
||||||
|
case "elite":
|
||||||
|
return []DnDMonsterTemplate{mischiefPickEnemy(zone, true, rng)}, true
|
||||||
|
case "boss":
|
||||||
|
return []DnDMonsterTemplate{dndBestiary[zone.Boss.BestiaryID]}, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model + persistence ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type mischiefContract struct {
|
||||||
|
ID string
|
||||||
|
BuyerID id.UserID
|
||||||
|
TargetID id.UserID
|
||||||
|
Tier string
|
||||||
|
Fee int // payout basis (base fee + escalations)
|
||||||
|
Paid int // what the buyer actually parted with
|
||||||
|
Status string
|
||||||
|
Signed bool
|
||||||
|
EscalationCount int
|
||||||
|
BlessingCount int
|
||||||
|
Source string
|
||||||
|
Outcome string
|
||||||
|
CreatedAt time.Time
|
||||||
|
WindowEndsAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed,
|
||||||
|
escalation_count, blessing_count, source, COALESCE(outcome, ''), created_at, window_ends_at`
|
||||||
|
|
||||||
|
func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error) {
|
||||||
|
c := &mischiefContract{}
|
||||||
|
err := row.Scan(&c.ID, &c.BuyerID, &c.TargetID, &c.Tier, &c.Fee, &c.Paid, &c.Status,
|
||||||
|
&c.Signed, &c.EscalationCount, &c.BlessingCount, &c.Source, &c.Outcome,
|
||||||
|
&c.CreatedAt, &c.WindowEndsAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertMischiefContract writes the contract, and FAILS if one is already live
|
||||||
|
// against this target (idx_mischief_one_live_per_target). That error is the
|
||||||
|
// serialization point for two buyers racing at the same victim — the caller must
|
||||||
|
// refund on it, not ignore it.
|
||||||
|
//
|
||||||
|
// Every timestamp is bound as a Go time.Time rather than left to
|
||||||
|
// CURRENT_TIMESTAMP: created_at and window_ends_at are both compared against
|
||||||
|
// bound Go times later (the daily cap, the due sweep), and mixing SQLite's own
|
||||||
|
// text stamp with the driver's encoding is how you get a comparison that is
|
||||||
|
// lexicographic-by-accident.
|
||||||
|
func insertMischiefContract(c *mischiefContract) error {
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO mischief_contracts
|
||||||
|
(contract_id, buyer_id, target_id, tier, fee, paid, status, signed, source,
|
||||||
|
created_at, window_ends_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
c.ID, string(c.BuyerID), string(c.TargetID), c.Tier, c.Fee, c.Paid,
|
||||||
|
c.Status, c.Signed, c.Source, c.CreatedAt, c.WindowEndsAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimMischiefForDelivery moves open → delivering and reports whether THIS
|
||||||
|
// caller won the race. Exactly one delivery per contract, whatever the ticker
|
||||||
|
// does across a restart.
|
||||||
|
func claimMischiefForDelivery(contractID string) bool {
|
||||||
|
res := db.ExecResult("mischief: claim delivery",
|
||||||
|
`UPDATE mischief_contracts SET status = ?
|
||||||
|
WHERE contract_id = ? AND status = ?`,
|
||||||
|
mischiefStatusDelivering, contractID, mischiefStatusOpen)
|
||||||
|
if res == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMischiefContract stamps the terminal state. Called only by the
|
||||||
|
// delivery path, which already holds the claim.
|
||||||
|
func resolveMischiefContract(contractID, status, outcome string) {
|
||||||
|
db.Exec("mischief: resolve contract",
|
||||||
|
`UPDATE mischief_contracts
|
||||||
|
SET status = ?, outcome = ?, resolved_at = ?
|
||||||
|
WHERE contract_id = ?`,
|
||||||
|
status, outcome, time.Now().UTC(), contractID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fizzleMischiefContract claims an open contract straight to fizzled — the
|
||||||
|
// target's expedition ended before the monster found them. Same CAS discipline
|
||||||
|
// as delivery, so a fizzle and a delivery can never both fire.
|
||||||
|
func fizzleMischiefContract(contractID string) bool {
|
||||||
|
return casMischiefStatus("mischief: fizzle contract", contractID,
|
||||||
|
mischiefStatusOpen, mischiefStatusFizzled, mischiefStatusFizzled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// abandonStaleMischief releases a stuck delivery. CAS off `delivering`, so a
|
||||||
|
// delivery that is merely slow (a long fight) cannot be swept out from under
|
||||||
|
// itself and double-refunded.
|
||||||
|
func abandonStaleMischief(contractID string) bool {
|
||||||
|
return casMischiefStatus("mischief: abandon stale delivery", contractID,
|
||||||
|
mischiefStatusDelivering, mischiefStatusFizzled, mischiefStatusFizzled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// casMischiefStatus is the one status transition every terminal path takes: move
|
||||||
|
// from → to only if the row is still in `from`, and report whether THIS caller
|
||||||
|
// won. Every transition is conditional, which is what makes a ticker double-fire
|
||||||
|
// or a restart mid-delivery unable to pay (or refund) twice.
|
||||||
|
func casMischiefStatus(label, contractID, from, to, outcome string) bool {
|
||||||
|
res := db.ExecResult(label,
|
||||||
|
`UPDATE mischief_contracts
|
||||||
|
SET status = ?, outcome = ?, resolved_at = ?
|
||||||
|
WHERE contract_id = ? AND status = ?`,
|
||||||
|
to, outcome, time.Now().UTC(), contractID, from)
|
||||||
|
if res == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadStaleMischiefDeliveries returns contracts stuck in `delivering` — claimed
|
||||||
|
// by a delivery that then crashed, or was killed mid-fight by a restart. Without
|
||||||
|
// this sweep the row lives forever: the target can never be targeted again (the
|
||||||
|
// live-contract check counts `delivering`) and the buyer's money is simply gone.
|
||||||
|
func loadStaleMischiefDeliveries(before time.Time) []mischiefContract {
|
||||||
|
return loadMischiefByStatus(mischiefStatusDelivering, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadMischiefDue returns open contracts whose window has closed.
|
||||||
|
func loadMischiefDue(now time.Time) []mischiefContract {
|
||||||
|
return loadMischiefByStatus(mischiefStatusOpen, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMischiefByStatus(status string, dueBy time.Time) []mischiefContract {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT `+mischiefCols+` FROM mischief_contracts
|
||||||
|
WHERE status = ? AND window_ends_at <= ?`,
|
||||||
|
status, dueBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []mischiefContract
|
||||||
|
for rows.Next() {
|
||||||
|
if c, err := scanMischief(rows); err == nil {
|
||||||
|
out = append(out, *c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveMischiefForTarget returns the contract already in flight against a target,
|
||||||
|
// or nil. One at a time: two monsters converging on the same person turns a
|
||||||
|
// mischief into a mugging.
|
||||||
|
func liveMischiefForTarget(targetID id.UserID) *mischiefContract {
|
||||||
|
row := db.Get().QueryRow(
|
||||||
|
`SELECT `+mischiefCols+` FROM mischief_contracts
|
||||||
|
WHERE target_id = ? AND status IN (?, ?)
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
string(targetID), mischiefStatusOpen, mischiefStatusDelivering)
|
||||||
|
c, err := scanMischief(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefTargetCoolingDown reports whether the target survived (or was floored
|
||||||
|
// by) a delivered contract too recently to be sent another.
|
||||||
|
//
|
||||||
|
// Only a DELIVERED contract cools a target down. A fizzle never reached them,
|
||||||
|
// and a crash-swept stranding never happened at all — counting either would sell
|
||||||
|
// cheap immunity: a target whose friend places a grunt and who then extracts pays
|
||||||
|
// 10% of a grunt fee for a full day of being untargetable.
|
||||||
|
func mischiefTargetCoolingDown(targetID id.UserID, now time.Time) (bool, time.Duration) {
|
||||||
|
var resolved time.Time
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT resolved_at FROM mischief_contracts
|
||||||
|
WHERE target_id = ? AND status = ? AND resolved_at IS NOT NULL
|
||||||
|
ORDER BY resolved_at DESC LIMIT 1`,
|
||||||
|
string(targetID), mischiefStatusDelivered).Scan(&resolved)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0 // no row (or unreadable stamp) == not cooling down
|
||||||
|
}
|
||||||
|
if wait := mischiefTargetCooldown - now.Sub(resolved.UTC()); wait > 0 {
|
||||||
|
return true, wait
|
||||||
|
}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefBuyerCountSince is how many contracts this buyer has placed in the
|
||||||
|
// window — the anti-spam cap. Fizzled ones count: the limit is on how often you
|
||||||
|
// may point a monster at someone, not on how often it connects.
|
||||||
|
func mischiefBuyerCountSince(buyerID id.UserID, since time.Time) int {
|
||||||
|
var n int
|
||||||
|
_ = db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM mischief_contracts WHERE buyer_id = ? AND created_at > ?`,
|
||||||
|
string(buyerID), since).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefBossCountSince counts boss-tier contracts aimed at a target inside the
|
||||||
|
// window, from anyone. Boss is the "end their expedition" button; one a week.
|
||||||
|
//
|
||||||
|
// Fizzled ones do NOT count, and that asymmetry with the buyer cap is deliberate:
|
||||||
|
// the buyer cap limits how often you may POINT a monster at someone, but this cap
|
||||||
|
// protects the target from being boss'd, and a boss that never found them is not
|
||||||
|
// something they need protecting from. Counting it would let a friendly buyer burn
|
||||||
|
// the target's weekly boss slot for the price of a fizzle rake.
|
||||||
|
func mischiefBossCountSince(targetID id.UserID, since time.Time) int {
|
||||||
|
var n int
|
||||||
|
_ = db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM mischief_contracts
|
||||||
|
WHERE target_id = ? AND tier = 'boss' AND created_at > ? AND status != ?`,
|
||||||
|
string(targetID), since, mischiefStatusFizzled).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eligibility ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// mischiefTargetable reports why a target can't be sent a monster, or "" if they
|
||||||
|
// can. The active expedition is the whole premise: the monster has to have
|
||||||
|
// somewhere to walk in from.
|
||||||
|
func (p *AdventurePlugin) mischiefTargetable(targetID id.UserID, tier mischiefTier, now time.Time) string {
|
||||||
|
dndChar, err := LoadDnDCharacter(targetID)
|
||||||
|
if err != nil || dndChar == nil {
|
||||||
|
return fmt.Sprintf("%s has no adventurer.", p.DisplayName(targetID))
|
||||||
|
}
|
||||||
|
// A dead character can still own an 'active' expedition row — that drift is
|
||||||
|
// exactly what the ambient ticker keeps a run-liveness net for. Don't sell a
|
||||||
|
// hit on a corpse.
|
||||||
|
if advChar, err := loadAdvCharacter(targetID); err != nil || advChar == nil || !advChar.Alive {
|
||||||
|
return fmt.Sprintf("%s is in no state to be attacked. Leave them be.", p.DisplayName(targetID))
|
||||||
|
}
|
||||||
|
if dndChar.Level < mischiefMinLevel {
|
||||||
|
return fmt.Sprintf("%s is only level %d — nobody's putting a price on a rookie's head (level %d+).",
|
||||||
|
p.DisplayName(targetID), dndChar.Level, mischiefMinLevel)
|
||||||
|
}
|
||||||
|
exp, err := getActiveExpedition(targetID)
|
||||||
|
if err != nil || exp == nil {
|
||||||
|
return fmt.Sprintf("%s isn't out on an expedition. Monsters don't do house calls.",
|
||||||
|
p.DisplayName(targetID))
|
||||||
|
}
|
||||||
|
if c := liveMischiefForTarget(targetID); c != nil {
|
||||||
|
return fmt.Sprintf("Somebody already has coin on %s. Wait for it to land.", p.DisplayName(targetID))
|
||||||
|
}
|
||||||
|
if cooling, wait := mischiefTargetCoolingDown(targetID, now); cooling {
|
||||||
|
return fmt.Sprintf("%s has only just shaken one off. Try again %s.",
|
||||||
|
p.DisplayName(targetID), formatDuration(wait))
|
||||||
|
}
|
||||||
|
if tier.Key == "boss" && mischiefBossCountSince(targetID, now.Add(-mischiefBossPerTargetWindow)) > 0 {
|
||||||
|
return fmt.Sprintf("%s has already had a boss sent after them this week. Once is plenty.",
|
||||||
|
p.DisplayName(targetID))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command surface ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleMischiefCmd(ctx MessageContext, args string) error {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(args))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, p.mischiefBoard())
|
||||||
|
}
|
||||||
|
switch strings.ToLower(fields[0]) {
|
||||||
|
case "board", "tiers", "help":
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, p.mischiefBoard())
|
||||||
|
case "status":
|
||||||
|
return p.mischiefStatusCmd(ctx)
|
||||||
|
case "send":
|
||||||
|
return p.mischiefSendCmd(ctx, fields[1:])
|
||||||
|
default:
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Unknown option. `!mischief` for the board · `!mischief send <tier> @user` · `!mischief status`")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) mischiefBoard() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("😈 **Mischief Makers** — put coin on an adventurer's head while they're out in a dungeon.\n")
|
||||||
|
b.WriteString("The monster comes from their own bracket, so it's always a fight worth watching. ")
|
||||||
|
b.WriteString("If they survive it, they keep a cut of your money — and the town finds out it was you.\n\n")
|
||||||
|
for _, t := range mischiefTiers {
|
||||||
|
b.WriteString(fmt.Sprintf("· **%s** — %s (signed: %s) — survivor keeps %s\n _%s_\n",
|
||||||
|
t.Display, fmtEuro(t.Fee), fmtEuro(mischiefSignedFee(t.Fee)),
|
||||||
|
fmtEuro(mischiefPurse(t.Fee, t.PayoutPct)), t.Blurb))
|
||||||
|
}
|
||||||
|
b.WriteString("\n`!mischief send <tier> @user` — anonymous · add `signed` to put your name on it up front.\n")
|
||||||
|
b.WriteString(fmt.Sprintf("_They have to be out on an expedition, level %d+. It lands about %s later._",
|
||||||
|
mischiefMinLevel, formatDuration(mischiefWindow)))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) mischiefStatusCmd(ctx MessageContext) error {
|
||||||
|
if c := liveMischiefForTarget(ctx.Sender); c != nil {
|
||||||
|
t, _ := mischiefTierByKey(c.Tier)
|
||||||
|
who := "Someone"
|
||||||
|
if c.Signed {
|
||||||
|
who = "**" + p.DisplayName(c.BuyerID) + "**"
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||||
|
"😈 %s has paid for a **%s** to find you. It arrives %s. Survive it and you keep %s.",
|
||||||
|
who, strings.ToLower(t.Display), formatDuration(time.Until(c.WindowEndsAt)),
|
||||||
|
fmtEuro(mischiefPurse(c.Fee, t.PayoutPct))))
|
||||||
|
}
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT `+mischiefCols+` FROM mischief_contracts
|
||||||
|
WHERE buyer_id = ? AND status IN (?, ?) ORDER BY created_at DESC`,
|
||||||
|
string(ctx.Sender), mischiefStatusOpen, mischiefStatusDelivering)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
var lines []string
|
||||||
|
for rows.Next() {
|
||||||
|
if c, err := scanMischief(rows); err == nil {
|
||||||
|
lines = append(lines, fmt.Sprintf("· a **%s** for **%s**, landing %s",
|
||||||
|
c.Tier, p.DisplayName(c.TargetID), formatDuration(time.Until(c.WindowEndsAt))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"😈 **Your contracts in flight**\n"+strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Nothing out on you, and nothing out from you. `!mischief` for the board.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) error {
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Usage: `!mischief send <grunt|mob|elite|boss> @user [signed]`")
|
||||||
|
}
|
||||||
|
tier, ok := mischiefTierByKey(fields[0])
|
||||||
|
if !ok {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"That's not a tier. Try `grunt`, `mob`, `elite` or `boss` — `!mischief` for the board.")
|
||||||
|
}
|
||||||
|
targetID, ok := p.ResolveUser(fields[1], ctx.RoomID)
|
||||||
|
if !ok {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Could not resolve that user. Usage: `!mischief send <tier> @user`")
|
||||||
|
}
|
||||||
|
if targetID == ctx.Sender {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"You can't put a price on your own head. Get help elsewhere.")
|
||||||
|
}
|
||||||
|
signed := len(fields) > 2 && strings.EqualFold(fields[2], "signed")
|
||||||
|
|
||||||
|
// Serialise this buyer's placement so a double-submit can't pass the daily
|
||||||
|
// cap twice and double-debit — same discipline as the duel escrow.
|
||||||
|
lock := p.advUserLock(ctx.Sender)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if n := mischiefBuyerCountSince(ctx.Sender, now.Add(-24*time.Hour)); n >= mischiefBuyerDailyCap {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||||
|
"You've already sent %d monsters today. Even mischief keeps office hours.", n))
|
||||||
|
}
|
||||||
|
if reason := p.mischiefTargetable(targetID, tier, now); reason != "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
price := tier.Fee
|
||||||
|
if signed {
|
||||||
|
price = mischiefSignedFee(tier.Fee)
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < price {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||||
|
"A %s runs %s — you have %s.", strings.ToLower(tier.Display), fmtEuro(price), fmtEuro(int(bal))))
|
||||||
|
}
|
||||||
|
if !p.euro.Debit(ctx.Sender, float64(price), "mischief_contract") {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &mischiefContract{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
BuyerID: ctx.Sender,
|
||||||
|
TargetID: targetID,
|
||||||
|
Tier: tier.Key,
|
||||||
|
Fee: tier.Fee,
|
||||||
|
Paid: price,
|
||||||
|
Status: mischiefStatusOpen,
|
||||||
|
Signed: signed,
|
||||||
|
Source: "matrix",
|
||||||
|
CreatedAt: now,
|
||||||
|
WindowEndsAt: now.Add(mischiefWindow),
|
||||||
|
}
|
||||||
|
// The one-live-contract-per-target index is the real guard: the check above
|
||||||
|
// ran under this buyer's lock, and another buyer could have slipped a contract
|
||||||
|
// onto the same target since. Losing that race costs the buyer nothing.
|
||||||
|
if err := insertMischiefContract(c); err != nil {
|
||||||
|
p.euro.Credit(ctx.Sender, float64(price), "mischief_refund_contested")
|
||||||
|
slog.Warn("mischief: contract insert rejected",
|
||||||
|
"buyer", ctx.Sender, "target", targetID, "err", err)
|
||||||
|
// Refund either way, but don't tell a buyer a rival outbid them when the
|
||||||
|
// write simply failed — they'd stop retrying and believe in a contract that
|
||||||
|
// does not exist. The index is the only thing that legitimately rejects here.
|
||||||
|
if liveMischiefForTarget(targetID) != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||||
|
"Somebody beat you to **%s** — there's already a monster on the way. You've been refunded.",
|
||||||
|
p.DisplayName(targetID)))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"The contract didn't take — something went wrong on our end. You've been refunded; try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.announceMischiefContract(c, tier)
|
||||||
|
emitMischiefContractNews(c, tier)
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
|
||||||
|
"😈 Done. A **%s** is on its way to **%s** — it finds them in about %s.\n%s",
|
||||||
|
strings.ToLower(tier.Display), p.DisplayName(targetID), formatDuration(mischiefWindow),
|
||||||
|
mischiefBuyerNote(signed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mischiefBuyerNote(signed bool) string {
|
||||||
|
if signed {
|
||||||
|
return "_Your name is on it. Everyone already knows._"
|
||||||
|
}
|
||||||
|
return "_Nobody knows it was you — unless they walk away from it._"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Announcements ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// announceMischiefContract tells the games room a hit is out. The buyer is named
|
||||||
|
// only if they paid to sign it; otherwise this is the whole point of the window —
|
||||||
|
// the room knows someone did it and doesn't know who.
|
||||||
|
func (p *AdventurePlugin) announceMischiefContract(c *mischiefContract, tier mischiefTier) {
|
||||||
|
gr := gamesRoom()
|
||||||
|
if gr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
who := "**Someone in town**"
|
||||||
|
if c.Signed {
|
||||||
|
who = fmt.Sprintf("**%s**", p.DisplayName(c.BuyerID))
|
||||||
|
}
|
||||||
|
p.SendMessage(gr, fmt.Sprintf(
|
||||||
|
"😈 %s has put %s on **%s**'s head — a **%s** is already looking for them out there.\n"+
|
||||||
|
"It finds them in about %s. If they walk away from it, they keep %s of that money — and we all find out who paid.",
|
||||||
|
who, fmtEuro(c.Paid), p.DisplayName(c.TargetID), strings.ToLower(tier.Display),
|
||||||
|
formatDuration(time.Until(c.WindowEndsAt)), fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) announceMischiefSurvived(c *mischiefContract, monsterName string, purse int) {
|
||||||
|
gr := gamesRoom()
|
||||||
|
if gr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The unseal. Anonymity is the buyer's to lose, and losing it is what makes
|
||||||
|
// a survival worth announcing.
|
||||||
|
p.SendMessage(gr, fmt.Sprintf(
|
||||||
|
"🛡️ **%s walked away from it.** The **%s** that came for them is dead, and %s is %s richer for the trouble.\n"+
|
||||||
|
"The coin came from **%s**.",
|
||||||
|
p.DisplayName(c.TargetID), monsterName, p.DisplayName(c.TargetID), fmtEuro(purse),
|
||||||
|
p.DisplayName(c.BuyerID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) announceMischiefDowned(c *mischiefContract, monsterName string) {
|
||||||
|
gr := gamesRoom()
|
||||||
|
if gr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
who := "Whoever paid for it"
|
||||||
|
if c.Signed {
|
||||||
|
who = fmt.Sprintf("**%s**, who paid for it,", p.DisplayName(c.BuyerID))
|
||||||
|
}
|
||||||
|
p.SendMessage(gr, fmt.Sprintf(
|
||||||
|
"💀 **%s didn't walk away.** A **%s** found them mid-run and put them on the floor. "+
|
||||||
|
"They're being carried home — expedition over, pockets lighter.\n%s keeps nothing: the money's gone to the community pot.",
|
||||||
|
p.DisplayName(c.TargetID), monsterName, who))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pete news ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Deploy Pete BEFORE gogobee whenever these types change: Pete 400s an unknown
|
||||||
|
// event_type, gogobee retries, and the bulletin parks forever.
|
||||||
|
|
||||||
|
func emitMischiefContractNews(c *mischiefContract, tier mischiefTier) {
|
||||||
|
target := charName(c.TargetID)
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buyer := ""
|
||||||
|
buyerUser := id.UserID("")
|
||||||
|
if c.Signed {
|
||||||
|
buyer = charName(c.BuyerID)
|
||||||
|
buyerUser = c.BuyerID
|
||||||
|
}
|
||||||
|
ts := nowUnix()
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("mischief_contract:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
||||||
|
EventType: "mischief_contract",
|
||||||
|
Tier: "priority",
|
||||||
|
Subject: target,
|
||||||
|
Opponent: buyer,
|
||||||
|
Boss: tier.Display,
|
||||||
|
Stakes: fmtEuro(c.Paid),
|
||||||
|
Level: charLevel(c.TargetID),
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, c.TargetID, buyerUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitMischiefResolvedNews files the survival or the maiming. On a survival the
|
||||||
|
// buyer is named whether or not they signed — that is the unseal, and it is the
|
||||||
|
// only brake on casual griefing. (`!news optout` still anonymizes them to "an
|
||||||
|
// adventurer", as it does everywhere else.)
|
||||||
|
func emitMischiefResolvedNews(c *mischiefContract, monsterName, outcome string, purse int) {
|
||||||
|
target := charName(c.TargetID)
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eventType := "mischief_downed"
|
||||||
|
buyer, buyerUser := "", id.UserID("")
|
||||||
|
if outcome == mischiefOutcomeSurvived {
|
||||||
|
eventType = "mischief_survived"
|
||||||
|
buyer, buyerUser = charName(c.BuyerID), c.BuyerID
|
||||||
|
} else if c.Signed {
|
||||||
|
buyer, buyerUser = charName(c.BuyerID), c.BuyerID
|
||||||
|
}
|
||||||
|
ts := nowUnix()
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("%s:%s:%d", eventType, eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
||||||
|
EventType: eventType,
|
||||||
|
Tier: "priority",
|
||||||
|
Subject: target,
|
||||||
|
Opponent: buyer,
|
||||||
|
Boss: monsterName,
|
||||||
|
Stakes: fmtEuro(purse),
|
||||||
|
Level: charLevel(c.TargetID),
|
||||||
|
Outcome: outcome,
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, c.TargetID, buyerUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitMischiefFizzledNews(c *mischiefContract, refund int) {
|
||||||
|
target := charName(c.TargetID)
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ts := nowUnix()
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: fmt.Sprintf("mischief_fizzled:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
|
||||||
|
EventType: "mischief_fizzled",
|
||||||
|
Tier: "bulletin",
|
||||||
|
Subject: target,
|
||||||
|
Stakes: fmtEuro(refund),
|
||||||
|
Outcome: "fizzled",
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, c.TargetID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefContractByID is the test/ops read path.
|
||||||
|
func mischiefContractByID(contractID string) (*mischiefContract, error) {
|
||||||
|
row := db.Get().QueryRow(
|
||||||
|
`SELECT `+mischiefCols+` FROM mischief_contracts WHERE contract_id = ?`, contractID)
|
||||||
|
c, err := scanMischief(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
445
internal/plugin/adventure_mischief_deliver.go
Normal file
445
internal/plugin/adventure_mischief_deliver.go
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// Mischief Makers (M1) — delivery.
|
||||||
|
//
|
||||||
|
// The sibling of the ambient ticker: once a contract's window closes, the
|
||||||
|
// monster has to actually find the target mid-run. runMischiefInterrupt is
|
||||||
|
// modelled line-for-line on runHarvestInterrupt (pick enemy → ambush nick →
|
||||||
|
// runZoneCombatRoster → close out), with two deliberate departures:
|
||||||
|
//
|
||||||
|
// - It never calls recordZoneKill and never advances zone state. The fight is
|
||||||
|
// extrinsic to the dungeon: somebody bought it, the dungeon didn't send it.
|
||||||
|
// Crediting it as a zone kill would let a buyer unlock a target's
|
||||||
|
// RequiresKill resource gates for them.
|
||||||
|
//
|
||||||
|
// - It never marks anybody dead. runHarvestInterrupt's loss path perma-kills;
|
||||||
|
// a *purchased* attack that lands while its victim is asleep must not be
|
||||||
|
// able to delete their character. Mischief maims: floor at 1 HP,
|
||||||
|
// force-extract, take a bite out of the un-banked coins.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mischiefTickInterval — how often we look for contracts whose window has
|
||||||
|
// closed. The window itself is the pacing; the tick just has to be fine-grained
|
||||||
|
// enough that "about an hour" isn't a lie.
|
||||||
|
const mischiefTickInterval = time.Minute
|
||||||
|
|
||||||
|
// mischiefDeliveryGrace — how long past its window a contract may sit in
|
||||||
|
// `delivering` before the sweep calls it stranded. Comfortably longer than any
|
||||||
|
// chain of auto-resolved fights takes, so a slow delivery is never swept out
|
||||||
|
// from under itself (and the CAS in abandonStaleMischief is the backstop if it
|
||||||
|
// somehow is).
|
||||||
|
const mischiefDeliveryGrace = 15 * time.Minute
|
||||||
|
|
||||||
|
// mischiefTreasureWeight / mischiefRenownXP — the survival extras. Deliberately
|
||||||
|
// modest: the purse is the reward, this is the garnish.
|
||||||
|
const (
|
||||||
|
mischiefTreasureWeight = 1.0
|
||||||
|
mischiefEliteRenownXP = 40
|
||||||
|
mischiefBossRenownXP = 120
|
||||||
|
mischiefBossCacheSize = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) mischiefTicker() {
|
||||||
|
ticker := time.NewTicker(mischiefTickInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
p.fireMischiefDeliveries(time.Now().UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireMischiefDeliveries walks contracts whose window has closed and either
|
||||||
|
// delivers the monster or fizzles the contract.
|
||||||
|
//
|
||||||
|
// The gates mirror the ambient ticker's, for the same reason: a mid-run event
|
||||||
|
// that talks over a live turn-based fight, or lands on top of the 06:00
|
||||||
|
// briefing, reads as a bug even when it isn't. A blocked contract is NOT
|
||||||
|
// fizzled — it simply waits for the next tick. The only thing that fizzles a
|
||||||
|
// contract is the expedition ending, which is what the buyer was actually
|
||||||
|
// betting against.
|
||||||
|
func (p *AdventurePlugin) fireMischiefDeliveries(now time.Time) {
|
||||||
|
if inAmbientQuietWindow(now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.sweepStaleMischief(now)
|
||||||
|
for _, c := range loadMischiefDue(now) {
|
||||||
|
contract := c
|
||||||
|
p.fireOneMischiefDelivery(&contract)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireOneMischiefDelivery resolves a single due contract under the TARGET's lock.
|
||||||
|
//
|
||||||
|
// The lock is not optional. hasActiveCombatSession only sees the turn-based
|
||||||
|
// engine; the target's own `!explore` / autopilot walk resolves its fights
|
||||||
|
// inline (runHarvestInterrupt → runZoneCombatRoster) under advUserLock and
|
||||||
|
// reports no session. Without taking that same lock, a delivery can run a second
|
||||||
|
// combat against the same character sheet concurrently — two LoadDnDCharacter →
|
||||||
|
// mutate → SaveDnDCharacter writers, last write wins, and a whole fight's HP cost
|
||||||
|
// vanishes. The boredom ticker takes this lock for exactly this reason.
|
||||||
|
//
|
||||||
|
// Everything below the lock runs on the auto-resolve path, which takes no user
|
||||||
|
// locks of its own, so there is nothing here to deadlock against.
|
||||||
|
func (p *AdventurePlugin) fireOneMischiefDelivery(contract *mischiefContract) {
|
||||||
|
target := contract.TargetID
|
||||||
|
|
||||||
|
lock := p.advUserLock(target)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
// Re-read under the lock: whatever the due-sweep saw, the expedition may have
|
||||||
|
// ended while we were queuing behind the target's own command.
|
||||||
|
exp, err := getActiveExpedition(target)
|
||||||
|
if err != nil || exp == nil || exp.Status != ExpeditionStatusActive {
|
||||||
|
p.fizzleMischief(contract)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same safety net the ambient ticker keeps: the expedition row can still
|
||||||
|
// say 'active' after the player has functionally left the dungeon.
|
||||||
|
if exp.RunID != "" {
|
||||||
|
if run, _ := getZoneRun(exp.RunID); run == nil || !run.IsActive() {
|
||||||
|
p.fizzleMischief(contract)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasActiveCombatSession(target) {
|
||||||
|
return // they're mid-fight; the monster can wait a minute
|
||||||
|
}
|
||||||
|
if !claimMischiefForDelivery(contract.ID) {
|
||||||
|
return // another tick (or another process) already has it
|
||||||
|
}
|
||||||
|
if err := p.deliverMischief(contract, exp); err != nil {
|
||||||
|
slog.Error("mischief: delivery failed",
|
||||||
|
"contract", contract.ID, "target", target, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepStaleMischief releases contracts stranded in `delivering` — the process
|
||||||
|
// died between the claim and the close-out (a restart mid-fight, a panic). The
|
||||||
|
// row would otherwise be immortal: the target can never be targeted again, and
|
||||||
|
// the buyer's money never comes back.
|
||||||
|
//
|
||||||
|
// The buyer is made whole in full rather than rake-charged. A stranded delivery
|
||||||
|
// is our crash, not a bet they lost.
|
||||||
|
func (p *AdventurePlugin) sweepStaleMischief(now time.Time) {
|
||||||
|
for _, c := range loadStaleMischiefDeliveries(now.Add(-mischiefDeliveryGrace)) {
|
||||||
|
contract := c
|
||||||
|
if !abandonStaleMischief(contract.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.euro.Credit(contract.BuyerID, float64(contract.Paid), "mischief_refund_stranded")
|
||||||
|
p.SendDM(contract.BuyerID, fmt.Sprintf(
|
||||||
|
"😾 Your **%s** never reached **%s** — something went wrong on our end. Fully refunded: %s.",
|
||||||
|
contract.Tier, p.DisplayName(contract.TargetID), fmtEuro(contract.Paid)))
|
||||||
|
slog.Warn("mischief: swept stranded delivery",
|
||||||
|
"contract", contract.ID, "target", contract.TargetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fizzleMischief refunds the buyer (minus the rake) when the monster arrives to
|
||||||
|
// an empty dungeon. The CAS is what makes the refund safe: a contract that was
|
||||||
|
// simultaneously claimed for delivery cannot also be refunded here.
|
||||||
|
func (p *AdventurePlugin) fizzleMischief(c *mischiefContract) {
|
||||||
|
if !fizzleMischiefContract(c.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refund := int(float64(c.Paid) * mischiefFizzleRefund)
|
||||||
|
rake := c.Paid - refund
|
||||||
|
if refund > 0 {
|
||||||
|
p.euro.Credit(c.BuyerID, float64(refund), "mischief_fizzle_refund")
|
||||||
|
}
|
||||||
|
if rake > 0 {
|
||||||
|
communityPotAdd(rake)
|
||||||
|
trackTaxPaid(c.BuyerID, rake)
|
||||||
|
}
|
||||||
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
|
"😾 Your **%s** got to the dungeon and found nobody home — **%s** was already out of there.\n"+
|
||||||
|
"You're refunded %s. The other %s stays with the town, for its trouble.",
|
||||||
|
c.Tier, p.DisplayName(c.TargetID), fmtEuro(refund), fmtEuro(rake)))
|
||||||
|
emitMischiefFizzledNews(c, refund)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverMischief runs the purchased fight and closes it out. By the time this
|
||||||
|
// is called the contract is claimed, so it resolves exactly once.
|
||||||
|
func (p *AdventurePlugin) deliverMischief(c *mischiefContract, exp *Expedition) error {
|
||||||
|
tier, ok := mischiefTierByKey(c.Tier)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown mischief tier %q", c.Tier)
|
||||||
|
}
|
||||||
|
target := c.TargetID
|
||||||
|
|
||||||
|
dndChar, err := LoadDnDCharacter(target)
|
||||||
|
if err != nil || dndChar == nil {
|
||||||
|
// The target evaporated between claim and delivery. Nothing to attack —
|
||||||
|
// treat it as a fizzle so the buyer isn't charged for a no-show.
|
||||||
|
p.refundClaimedMischief(c, "target has no character")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attacker comes from the target's own bracket, not the dungeon they are
|
||||||
|
// standing in. See mischiefBracketZones.
|
||||||
|
bracket := mischiefBracketZone(dndChar.Level)
|
||||||
|
rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
|
||||||
|
chain, ambush := mischiefMonsters(tier.Key, bracket, rng)
|
||||||
|
// Every link, not just the first: a zone-pool entry with no bestiary row
|
||||||
|
// yields a zero-value template, and a nameless 0-HP monster as fight 2 of a
|
||||||
|
// mob would read as the attack inexplicably giving up halfway.
|
||||||
|
if len(chain) == 0 {
|
||||||
|
p.refundClaimedMischief(c, "no monster available")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, m := range chain {
|
||||||
|
if m.Name == "" {
|
||||||
|
p.refundClaimedMischief(c, "bestiary miss in the "+bracket.Display+" pool")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
narration, monsterName, survived := p.runMischiefInterrupt(target, exp, bracket, chain, ambush)
|
||||||
|
|
||||||
|
if survived {
|
||||||
|
p.resolveMischiefSurvived(c, tier, exp, bracket, monsterName, narration)
|
||||||
|
} else {
|
||||||
|
p.resolveMischiefDowned(c, exp, monsterName, narration)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundClaimedMischief unwinds a contract that was already claimed for delivery
|
||||||
|
// but couldn't be staged. The buyer gets everything back — this is our fault,
|
||||||
|
// not a fizzle they gambled on.
|
||||||
|
func (p *AdventurePlugin) refundClaimedMischief(c *mischiefContract, reason string) {
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusFizzled, mischiefStatusFizzled)
|
||||||
|
p.euro.Credit(c.BuyerID, float64(c.Paid), "mischief_refund_unstageable")
|
||||||
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
|
"😾 Your **%s** never made it out of the gate. Fully refunded: %s.", c.Tier, fmtEuro(c.Paid)))
|
||||||
|
slog.Warn("mischief: contract unstageable", "contract", c.ID, "reason", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMischiefInterrupt fights the whole chain and reports whether the target was
|
||||||
|
// still standing at the end.
|
||||||
|
//
|
||||||
|
// Survival is read off the target's HP, not off PlayerWon. The engine's timeout
|
||||||
|
// is a retreat, not a lethal blow — a target who ran out the clock with HP left
|
||||||
|
// held the thing off, and a bought monster that merely *outlasted* them has not
|
||||||
|
// earned a maiming. The chain continues while they stand: a mob is three
|
||||||
|
// attackers, and beating the first two doesn't mean the third goes home.
|
||||||
|
func (p *AdventurePlugin) runMischiefInterrupt(
|
||||||
|
target id.UserID,
|
||||||
|
exp *Expedition,
|
||||||
|
bracket ZoneDefinition,
|
||||||
|
chain []DnDMonsterTemplate,
|
||||||
|
ambush bool,
|
||||||
|
) (narration, monsterName string, survived bool) {
|
||||||
|
var b strings.Builder
|
||||||
|
last := chain[len(chain)-1].Name
|
||||||
|
|
||||||
|
for i, monster := range chain {
|
||||||
|
// The ambush is the attacker's privilege — it was lying in wait, and the
|
||||||
|
// target had no reason to expect it. Same one-free-swing approximation the
|
||||||
|
// harvest interrupt uses, with the same wounded-entrant clamp so it can't
|
||||||
|
// pre-empt the fight outright.
|
||||||
|
if ambush && i == 0 {
|
||||||
|
if dc, _ := LoadDnDCharacter(target); dc != nil {
|
||||||
|
nick := clampSurpriseNick(
|
||||||
|
surpriseRoundNick(monster, int(bracket.Tier)), dc.HPCurrent, dc.HPMax)
|
||||||
|
if nick > 0 {
|
||||||
|
dc.HPCurrent -= nick
|
||||||
|
_ = SaveDnDCharacter(dc)
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
"_It was waiting for you. **%s** opens with a free swing — %d HP._\n",
|
||||||
|
monster.Name, nick))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preHP, _ := dndHPSnapshot(target)
|
||||||
|
pres, _, err := p.runZoneCombatRoster(
|
||||||
|
fightRoster(target), monster, int(bracket.Tier), dungeonCombatPhases, exp.DMMood)
|
||||||
|
if err != nil {
|
||||||
|
// Mid-chain build failure. The target keeps whatever HP they had; treat
|
||||||
|
// them as having survived rather than maiming them on our bug.
|
||||||
|
slog.Error("mischief: combat error", "target", target, "err", err)
|
||||||
|
b.WriteString(fmt.Sprintf("_(The %s never quite arrives.)_\n", monster.Name))
|
||||||
|
return b.String(), last, true
|
||||||
|
}
|
||||||
|
postHP, maxHP := dndHPSnapshot(target)
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||||
|
switch {
|
||||||
|
case pres.Seats[0].PlayerWon:
|
||||||
|
b.WriteString(fmt.Sprintf("✅ **%s** down — %d→%d / %d HP.\n",
|
||||||
|
monster.Name, preHP, postHP, maxHP))
|
||||||
|
case postHP > 0:
|
||||||
|
// The engine's timeout: they held it off without killing it. That still
|
||||||
|
// pays — but say so plainly, or a stalemate reads as a clean kill and the
|
||||||
|
// player wonders why the thing is still following them.
|
||||||
|
b.WriteString(fmt.Sprintf("⏳ **%s** breaks off, still breathing. You're at %d / %d HP.\n",
|
||||||
|
monster.Name, postHP, maxHP))
|
||||||
|
}
|
||||||
|
if postHP <= 0 {
|
||||||
|
return b.String(), monster.Name, false
|
||||||
|
}
|
||||||
|
if i < len(chain)-1 {
|
||||||
|
b.WriteString("_And it isn't alone._\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String(), last, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// floorMischiefRoster raises every seat the delivery put on the floor back to
|
||||||
|
// 1 HP. It runs on BOTH outcomes, and that is not belt-and-braces: the leader
|
||||||
|
// can win a fight their friend went down in, and a mischief delivery
|
||||||
|
// deliberately skips closeOutZoneWin/closeOutZoneLoss (which is what normally
|
||||||
|
// marks a 0-HP seat dead). Without this, a dropped party member is left alive at
|
||||||
|
// 0 HP — a state nothing else in the game produces, and one the gates that read
|
||||||
|
// `HPCurrent <= 0` (expedition autorun, for one) treat as broken rather than
|
||||||
|
// dead. Nobody dies for money; nobody gets stuck at zero for it either.
|
||||||
|
func (p *AdventurePlugin) floorMischiefRoster(target id.UserID) {
|
||||||
|
for _, uid := range fightRoster(target) {
|
||||||
|
if isCompanionSeat(uid) {
|
||||||
|
continue // he takes no wounds home; he has no rows to floor
|
||||||
|
}
|
||||||
|
floorHPAtOne(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMischiefSurvived pays the target and unseals the buyer.
|
||||||
|
//
|
||||||
|
// The purse is a percentage of the base fee — never of what the buyer paid — so
|
||||||
|
// it is always strictly less than the buyer's outlay. See mischiefPurse.
|
||||||
|
func (p *AdventurePlugin) resolveMischiefSurvived(
|
||||||
|
c *mischiefContract, tier mischiefTier, exp *Expedition, bracket ZoneDefinition,
|
||||||
|
monsterName, narration string,
|
||||||
|
) {
|
||||||
|
// The leader lived; a friend of theirs may not have. See floorMischiefRoster.
|
||||||
|
p.floorMischiefRoster(c.TargetID)
|
||||||
|
|
||||||
|
purse := mischiefPurse(c.Fee, tier.PayoutPct)
|
||||||
|
p.euro.Credit(c.TargetID, float64(purse), "mischief_survived")
|
||||||
|
|
||||||
|
// Everything the buyer spent that isn't the purse — the rake, plus the whole
|
||||||
|
// sign surcharge — is a sink. That is what makes the payout cap bite.
|
||||||
|
if rake := c.Paid - purse; rake > 0 {
|
||||||
|
communityPotAdd(rake)
|
||||||
|
trackTaxPaid(c.BuyerID, rake)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extras by tier. Treasure rolls against the zone they're actually in — the
|
||||||
|
// loot is scavenged off a corpse in that dungeon, wherever the thing came from.
|
||||||
|
var extras []string
|
||||||
|
switch tier.Key {
|
||||||
|
case "mob":
|
||||||
|
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||||
|
case "elite":
|
||||||
|
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||||
|
if before, after, err := addRenownXP(c.TargetID, mischiefEliteRenownXP); err == nil {
|
||||||
|
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
|
||||||
|
extras = append(extras, "The story of it is already going round town.")
|
||||||
|
}
|
||||||
|
case "boss":
|
||||||
|
p.rollZoneTreasure(c.TargetID, exp.ZoneID, mischiefTreasureWeight)
|
||||||
|
if before, after, err := addRenownXP(c.TargetID, mischiefBossRenownXP); err == nil {
|
||||||
|
p.announceRenown(c.TargetID, renownLevelFor(before), renownLevelFor(after))
|
||||||
|
extras = append(extras, "People are going to be telling this one for a while.")
|
||||||
|
}
|
||||||
|
for _, item := range consumableCache(int(bracket.Tier), mischiefBossCacheSize) {
|
||||||
|
it := item
|
||||||
|
p.grantZoneItem(c.TargetID, &it, "🧪")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
|
||||||
|
var dm strings.Builder
|
||||||
|
dm.WriteString("😈 **Somebody paid for that.**\n\n")
|
||||||
|
dm.WriteString(narration)
|
||||||
|
dm.WriteString(fmt.Sprintf(
|
||||||
|
"\n🛡️ You're still standing, and the coin was never really theirs. **%s** is yours.\n",
|
||||||
|
fmtEuro(purse)))
|
||||||
|
dm.WriteString(fmt.Sprintf("It was **%s** who paid to have you killed. Do with that what you like.",
|
||||||
|
p.DisplayName(c.BuyerID)))
|
||||||
|
for _, e := range extras {
|
||||||
|
dm.WriteString("\n_" + e + "_")
|
||||||
|
}
|
||||||
|
p.SendDM(c.TargetID, dm.String())
|
||||||
|
|
||||||
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
|
"🛡️ **%s walked away from your %s.** They keep %s of your money, and the town knows your name now.",
|
||||||
|
p.DisplayName(c.TargetID), c.Tier, fmtEuro(purse)))
|
||||||
|
|
||||||
|
p.announceMischiefSurvived(c, monsterName, purse)
|
||||||
|
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeSurvived, purse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMischiefDowned maims the target: HP floored at 1, expedition
|
||||||
|
// force-extracted, un-banked coins docked. Nobody dies for money.
|
||||||
|
//
|
||||||
|
// The whole fee goes to the community pot — never back to the buyer. A
|
||||||
|
// successful hit buys glory, not a rebate; refunding it would make repeat
|
||||||
|
// contracts free and turn the cap into a rounding error.
|
||||||
|
func (p *AdventurePlugin) resolveMischiefDowned(
|
||||||
|
c *mischiefContract, exp *Expedition, monsterName, narration string,
|
||||||
|
) {
|
||||||
|
// Every seat, not just the leader: a party that went down with them went down
|
||||||
|
// to a bought monster too, and the no-perma-death rule is about the purchase,
|
||||||
|
// not about who was holding the torch. Must run BEFORE the extract, which
|
||||||
|
// releases the party and would leave us nobody to floor.
|
||||||
|
p.floorMischiefRoster(c.TargetID)
|
||||||
|
|
||||||
|
// forcedExtractExpedition retires the region runs and releases the party itself;
|
||||||
|
// only the zone run has to be closed here.
|
||||||
|
_ = abandonZoneRun(c.TargetID)
|
||||||
|
_, tax, err := forcedExtractExpedition(exp.ID, "mischief contract")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("mischief: force extract failed", "expedition", exp.ID, "err", err)
|
||||||
|
}
|
||||||
|
// The un-banked-coin loss. forcedExtractExpedition computes the 20% cut and
|
||||||
|
// leaves the debit to the caller; for a maiming, this is the caller.
|
||||||
|
if tax > 0 {
|
||||||
|
p.euro.Debit(c.TargetID, float64(tax), "mischief_downed_loss")
|
||||||
|
}
|
||||||
|
|
||||||
|
communityPotAdd(c.Paid)
|
||||||
|
trackTaxPaid(c.BuyerID, c.Paid)
|
||||||
|
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
|
||||||
|
|
||||||
|
var dm strings.Builder
|
||||||
|
dm.WriteString("😈 **Somebody paid for that.**\n\n")
|
||||||
|
dm.WriteString(narration)
|
||||||
|
dm.WriteString(fmt.Sprintf(
|
||||||
|
"\n💀 **%s** put you down. You wake up on a cart headed home — alive, which is more than the contract asked for.\n",
|
||||||
|
monsterName))
|
||||||
|
dm.WriteString("Your expedition is over.")
|
||||||
|
if tax > 0 {
|
||||||
|
dm.WriteString(fmt.Sprintf(" Whatever you hadn't banked yet is gone — %s of it.", fmtEuro(tax)))
|
||||||
|
}
|
||||||
|
if c.Signed {
|
||||||
|
dm.WriteString(fmt.Sprintf("\n\nThe contract was signed: **%s** paid for it.", p.DisplayName(c.BuyerID)))
|
||||||
|
} else {
|
||||||
|
dm.WriteString("\n\nNobody's saying who paid.")
|
||||||
|
}
|
||||||
|
p.SendDM(c.TargetID, dm.String())
|
||||||
|
|
||||||
|
p.SendDM(c.BuyerID, fmt.Sprintf(
|
||||||
|
"💀 Your **%s** found **%s** and put them on the floor. Their expedition is over.\n"+
|
||||||
|
"_You get nothing back — %s went to the community pot. You did this for the story._",
|
||||||
|
c.Tier, p.DisplayName(c.TargetID), fmtEuro(c.Paid)))
|
||||||
|
|
||||||
|
p.announceMischiefDowned(c, monsterName)
|
||||||
|
emitMischiefResolvedNews(c, monsterName, mischiefOutcomeDowned, 0)
|
||||||
|
}
|
||||||
564
internal/plugin/adventure_mischief_test.go
Normal file
564
internal/plugin/adventure_mischief_test.go
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMischiefTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedContract(t *testing.T, buyer, target id.UserID, tier string, signed bool) *mischiefContract {
|
||||||
|
t.Helper()
|
||||||
|
td, ok := mischiefTierByKey(tier)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unknown tier %q", tier)
|
||||||
|
}
|
||||||
|
paid := td.Fee
|
||||||
|
if signed {
|
||||||
|
paid = mischiefSignedFee(td.Fee)
|
||||||
|
}
|
||||||
|
c := &mischiefContract{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
BuyerID: buyer,
|
||||||
|
TargetID: target,
|
||||||
|
Tier: td.Key,
|
||||||
|
Fee: td.Fee,
|
||||||
|
Paid: paid,
|
||||||
|
Status: mischiefStatusOpen,
|
||||||
|
Signed: signed,
|
||||||
|
Source: "matrix",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
|
||||||
|
}
|
||||||
|
if err := insertMischiefContract(c); err != nil {
|
||||||
|
t.Fatalf("seed contract (%s → %s): %v", buyer, target, err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// The anti-collusion invariant, and the only reason no danger multiplier is
|
||||||
|
// needed: whatever the tier and whether or not the buyer signed, the target's
|
||||||
|
// survival purse is strictly less than what the buyer parted with. Two players
|
||||||
|
// who try to move money this way lose to !baltransfer, which is free.
|
||||||
|
func TestMischiefPurseNeverExceedsOutlay(t *testing.T) {
|
||||||
|
for _, tier := range mischiefTiers {
|
||||||
|
purse := mischiefPurse(tier.Fee, tier.PayoutPct)
|
||||||
|
for _, paid := range []int{tier.Fee, mischiefSignedFee(tier.Fee)} {
|
||||||
|
if purse >= paid {
|
||||||
|
t.Errorf("%s: purse %d >= buyer outlay %d — collusion beats !baltransfer",
|
||||||
|
tier.Key, purse, paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tier.PayoutPct > 0.75 {
|
||||||
|
t.Errorf("%s: payout %.2f exceeds the 75%% cap", tier.Key, tier.PayoutPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cap is enforced in code, not just in the table — an escalation (M2) raises
|
||||||
|
// the payout basis, and nothing it can do may push the purse past 75%.
|
||||||
|
func TestMischiefPurseHardCap(t *testing.T) {
|
||||||
|
if got := mischiefPurse(1000, 0.95); got != 750 {
|
||||||
|
t.Fatalf("mischiefPurse(1000, 0.95) = %d, want 750 (capped)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefSignedFee(t *testing.T) {
|
||||||
|
// The tier table's signed prices, as priced in M0.
|
||||||
|
want := map[string]int{"grunt": 50, "mob": 125, "elite": 438, "boss": 1500}
|
||||||
|
for _, tier := range mischiefTiers {
|
||||||
|
if got := mischiefSignedFee(tier.Fee); got != want[tier.Key] {
|
||||||
|
t.Errorf("%s signed fee = %d, want %d", tier.Key, got, want[tier.Key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one of delivery and fizzle may claim a contract, however many ticks
|
||||||
|
// race for it. Without this a restart mid-delivery could both maim the target
|
||||||
|
// and refund the buyer.
|
||||||
|
func TestMischiefClaimIsExactlyOnce(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
c := seedContract(t, "@buyer:x", "@target:x", "elite", false)
|
||||||
|
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("first claim should win")
|
||||||
|
}
|
||||||
|
if claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Error("second claim won — a contract could be delivered twice")
|
||||||
|
}
|
||||||
|
if fizzleMischiefContract(c.ID) {
|
||||||
|
t.Error("fizzle won after the delivery claim — buyer would be refunded a hit that landed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false)
|
||||||
|
if !fizzleMischiefContract(c2.ID) {
|
||||||
|
t.Fatal("first fizzle should win")
|
||||||
|
}
|
||||||
|
if claimMischiefForDelivery(c2.ID) {
|
||||||
|
t.Error("delivery claimed a fizzled contract — buyer would pay twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a closed window is due, and a resolved contract never comes back.
|
||||||
|
func TestMischiefDueWindow(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c := seedContract(t, "@buyer:x", "@target:x", "mob", false)
|
||||||
|
|
||||||
|
if due := loadMischiefDue(now); len(due) != 0 {
|
||||||
|
t.Fatalf("contract is due before its window closed: %d", len(due))
|
||||||
|
}
|
||||||
|
due := loadMischiefDue(now.Add(mischiefWindow + time.Minute))
|
||||||
|
if len(due) != 1 || due[0].ID != c.ID {
|
||||||
|
t.Fatalf("want the contract due after its window; got %d", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
if due := loadMischiefDue(now.Add(2 * mischiefWindow)); len(due) != 0 {
|
||||||
|
t.Error("a resolved contract came back around")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One live contract per target: two monsters converging on the same person is a
|
||||||
|
// mugging, not mischief.
|
||||||
|
func TestMischiefOneLiveContractPerTarget(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
target := id.UserID("@target:x")
|
||||||
|
if liveMischiefForTarget(target) != nil {
|
||||||
|
t.Fatal("no contract seeded, but one is live")
|
||||||
|
}
|
||||||
|
c := seedContract(t, "@buyer:x", target, "grunt", false)
|
||||||
|
if live := liveMischiefForTarget(target); live == nil || live.ID != c.ID {
|
||||||
|
t.Fatal("open contract should be live")
|
||||||
|
}
|
||||||
|
// A claimed-but-unresolved contract still blocks — the monster is en route.
|
||||||
|
claimMischiefForDelivery(c.ID)
|
||||||
|
if liveMischiefForTarget(target) == nil {
|
||||||
|
t.Error("a delivering contract should still block a second one")
|
||||||
|
}
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
|
||||||
|
if liveMischiefForTarget(target) != nil {
|
||||||
|
t.Error("a resolved contract still reads as live")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The post-resolution breather. Also the regression guard for the modernc
|
||||||
|
// datetime hazard: resolved_at round-trips through the driver's own encoding
|
||||||
|
// (bound Go time in, time.Time out), so the row has to be a real one — a
|
||||||
|
// hand-written stamp in some other format would compare lexicographically and
|
||||||
|
// silently never expire.
|
||||||
|
func TestMischiefTargetCooldown(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
target := id.UserID("@target:x")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
if cooling, _ := mischiefTargetCoolingDown(target, now); cooling {
|
||||||
|
t.Fatal("a target with no history is cooling down")
|
||||||
|
}
|
||||||
|
c := seedContract(t, "@buyer:x", target, "grunt", false)
|
||||||
|
resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
|
||||||
|
cooling, wait := mischiefTargetCoolingDown(target, now)
|
||||||
|
if !cooling {
|
||||||
|
t.Fatal("a just-resolved target should be cooling down")
|
||||||
|
}
|
||||||
|
// resolved_at is stamped a hair after `now`, so allow a second of slack.
|
||||||
|
if wait <= 0 || wait > mischiefTargetCooldown+time.Second {
|
||||||
|
t.Errorf("wait = %s, want within (0, %s]", wait, mischiefTargetCooldown)
|
||||||
|
}
|
||||||
|
if cooling, _ := mischiefTargetCoolingDown(target, now.Add(mischiefTargetCooldown+time.Minute)); cooling {
|
||||||
|
t.Error("still cooling down after the cooldown elapsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limits: contracts per buyer per day, and boss-tier per target per week.
|
||||||
|
// Fizzled contracts count against the buyer — the limit is on how often you may
|
||||||
|
// point a monster at someone, not on how often it connects.
|
||||||
|
func TestMischiefRateLimits(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
buyer := id.UserID("@buyer:x")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
dayAgo := now.Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
if n := mischiefBuyerCountSince(buyer, dayAgo); n != 0 {
|
||||||
|
t.Fatalf("fresh buyer count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
c1 := seedContract(t, buyer, "@a:x", "grunt", false)
|
||||||
|
seedContract(t, buyer, "@b:x", "mob", false)
|
||||||
|
fizzleMischiefContract(c1.ID)
|
||||||
|
if n := mischiefBuyerCountSince(buyer, dayAgo); n != mischiefBuyerDailyCap {
|
||||||
|
t.Errorf("buyer count = %d, want %d (a fizzle still counts)", n, mischiefBuyerDailyCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := id.UserID("@whale:x")
|
||||||
|
weekAgo := now.Add(-mischiefBossPerTargetWindow)
|
||||||
|
if n := mischiefBossCountSince(target, weekAgo); n != 0 {
|
||||||
|
t.Fatalf("fresh boss count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
elite := seedContract(t, "@other:x", target, "elite", false)
|
||||||
|
if n := mischiefBossCountSince(target, weekAgo); n != 0 {
|
||||||
|
t.Error("an elite contract counted against the boss-per-week limit")
|
||||||
|
}
|
||||||
|
// Resolve it first: only one contract may be live against a target at a time,
|
||||||
|
// and the database enforces that (idx_mischief_one_live_per_target).
|
||||||
|
resolveMischiefContract(elite.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
|
||||||
|
seedContract(t, "@other:x", target, "boss", false)
|
||||||
|
if n := mischiefBossCountSince(target, weekAgo); n != 1 {
|
||||||
|
t.Errorf("boss count = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The database, not a read-then-write check, is what stops two buyers racing a
|
||||||
|
// monster at the same target. The placement path holds only the buyer's lock, so
|
||||||
|
// without this index both inserts would land and the target would be hit twice.
|
||||||
|
func TestMischiefSecondLiveContractIsRejected(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
target := id.UserID("@target:x")
|
||||||
|
seedContract(t, "@buyer1:x", target, "grunt", false)
|
||||||
|
|
||||||
|
second := &mischiefContract{
|
||||||
|
ID: uuid.NewString(), BuyerID: "@buyer2:x", TargetID: target,
|
||||||
|
Tier: "elite", Fee: 350, Paid: 350, Status: mischiefStatusOpen,
|
||||||
|
Source: "matrix", CreatedAt: time.Now().UTC(),
|
||||||
|
WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
|
||||||
|
}
|
||||||
|
if err := insertMischiefContract(second); err == nil {
|
||||||
|
t.Fatal("a second live contract was accepted — the target can be hit twice at once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delivery that dies mid-flight (restart, panic) must not strand the contract:
|
||||||
|
// the row would block the target forever and the buyer's money would be gone.
|
||||||
|
func TestMischiefStaleDeliverySweep(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c := seedContract(t, "@buyer:x", "@target:x", "boss", false)
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside the grace period a slow delivery is left alone.
|
||||||
|
if stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow)); len(stale) != 0 {
|
||||||
|
t.Errorf("a delivery still inside its grace window was called stranded: %d", len(stale))
|
||||||
|
}
|
||||||
|
|
||||||
|
stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow + mischiefDeliveryGrace + time.Minute))
|
||||||
|
if len(stale) != 1 || stale[0].ID != c.ID {
|
||||||
|
t.Fatalf("stranded delivery not found: %d", len(stale))
|
||||||
|
}
|
||||||
|
if !abandonStaleMischief(c.ID) {
|
||||||
|
t.Fatal("abandon should win on a delivering row")
|
||||||
|
}
|
||||||
|
if abandonStaleMischief(c.ID) {
|
||||||
|
t.Error("abandon won twice — the buyer would be refunded twice")
|
||||||
|
}
|
||||||
|
// And the target is free again.
|
||||||
|
if liveMischiefForTarget("@target:x") != nil {
|
||||||
|
t.Error("a swept contract still blocks the target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The monster is priced against the TARGET's bracket, not the dungeon they are
|
||||||
|
// standing in — that is what the M0 fee table measured.
|
||||||
|
func TestMischiefBracketZones(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
level int
|
||||||
|
tier ZoneTier
|
||||||
|
}{{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3}, {13, 4}, {17, 4}, {18, 5}, {20, 5}} {
|
||||||
|
zone := mischiefBracketZone(tc.level)
|
||||||
|
if zone.Tier != tc.tier {
|
||||||
|
t.Errorf("level %d draws from tier %d, want %d", tc.level, zone.Tier, tc.tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fight shapes the fee table was priced against. Changing any of these
|
||||||
|
// invalidates the pricing, so they are pinned.
|
||||||
|
func TestMischiefMonsterChains(t *testing.T) {
|
||||||
|
zone := mischiefBracketZone(10) // tier 3
|
||||||
|
rng := rand.New(rand.NewPCG(0x6d69, 1))
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
tier string
|
||||||
|
wantLen int
|
||||||
|
wantAmbush bool
|
||||||
|
}{
|
||||||
|
{"grunt", 1, false},
|
||||||
|
{"mob", 3, false},
|
||||||
|
{"elite", 1, true},
|
||||||
|
{"boss", 1, true},
|
||||||
|
} {
|
||||||
|
chain, ambush := mischiefMonsters(tc.tier, zone, rng)
|
||||||
|
if len(chain) != tc.wantLen {
|
||||||
|
t.Errorf("%s: chain of %d, want %d", tc.tier, len(chain), tc.wantLen)
|
||||||
|
}
|
||||||
|
if ambush != tc.wantAmbush {
|
||||||
|
t.Errorf("%s: ambush = %v, want %v", tc.tier, ambush, tc.wantAmbush)
|
||||||
|
}
|
||||||
|
for _, m := range chain {
|
||||||
|
if m.Name == "" {
|
||||||
|
t.Errorf("%s: chain holds an empty monster — the bestiary lookup missed", tc.tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss tier sends the zone's actual boss; elite sends something flagged elite.
|
||||||
|
if chain, _ := mischiefMonsters("boss", zone, rng); chain[0].ID != zone.Boss.BestiaryID {
|
||||||
|
t.Errorf("boss tier sent %q, want the zone boss %q", chain[0].ID, zone.Boss.BestiaryID)
|
||||||
|
}
|
||||||
|
elite, _ := mischiefMonsters("elite", zone, rng)
|
||||||
|
found := false
|
||||||
|
for _, e := range zone.Enemies {
|
||||||
|
if e.BestiaryID == elite[0].ID && e.IsElite {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("elite tier sent %q, which isn't an elite in %s", elite[0].ID, zone.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── End-to-end: the delivery path against a real character ───────────────────
|
||||||
|
|
||||||
|
// seedMischiefTarget builds a real, playable adventurer on a live expedition —
|
||||||
|
// the thing a contract actually lands on. Unit tests on the contract row prove
|
||||||
|
// nothing about the fight; these drive runMischiefInterrupt and the close-outs.
|
||||||
|
func seedMischiefTarget(t *testing.T, uid id.UserID, level, hp int) *Expedition {
|
||||||
|
t.Helper()
|
||||||
|
if err := createAdvCharacter(uid, "target"+uid.Localpart()); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(&DnDCharacter{
|
||||||
|
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||||||
|
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||||
|
HPMax: hp, HPCurrent: hp, ArmorClass: 18,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||||
|
}
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startZoneRun: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, coins_earned)
|
||||||
|
VALUES (?, ?, 'goblin_warrens', ?, 'active', ?, 500)`,
|
||||||
|
"exp-"+uid.Localpart(), string(uid), run.RunID, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("seed expedition: %v", err)
|
||||||
|
}
|
||||||
|
exp, err := getActiveExpedition(uid)
|
||||||
|
if err != nil || exp == nil {
|
||||||
|
t.Fatalf("getActiveExpedition: %v", err)
|
||||||
|
}
|
||||||
|
return exp
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grunt against a healthy level-5 fighter: the fight actually runs, it costs
|
||||||
|
// HP, and they live. This is the "mostly theatre" tier doing what it was priced
|
||||||
|
// to do.
|
||||||
|
func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
uid := id.UserID("@grunted:example.org")
|
||||||
|
exp := seedMischiefTarget(t, uid, 5, 60)
|
||||||
|
|
||||||
|
bracket := mischiefBracketZone(5)
|
||||||
|
chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2)))
|
||||||
|
|
||||||
|
_, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush)
|
||||||
|
if !survived {
|
||||||
|
t.Fatalf("a level-5 fighter at full HP died to a grunt (%s) — the tier is mispriced", monster)
|
||||||
|
}
|
||||||
|
hp, _ := dndHPSnapshot(uid)
|
||||||
|
if hp <= 0 {
|
||||||
|
t.Errorf("survivor is at %d HP", hp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The boss tier is the dangerous one, and the one where the no-perma-death rule
|
||||||
|
// has to hold. A level-3 caster-grade sheet against a tier-1 boss goes down —
|
||||||
|
// and must come back up at 1 HP with the expedition force-extracted, never dead.
|
||||||
|
//
|
||||||
|
// This is the property that separates mischief from paid murder: it is bought,
|
||||||
|
// and it lands while the victim may well be asleep.
|
||||||
|
func TestMischiefDelivery_DownedTargetIsMaimedNotKilled(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
uid := id.UserID("@doomed:example.org")
|
||||||
|
// 1 HP: whatever the dice do, this fight ends with them on the floor.
|
||||||
|
exp := seedMischiefTarget(t, uid, 3, 1)
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:example.org", uid, "boss", false)
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
if err := p.deliverMischief(c, exp); err != nil {
|
||||||
|
t.Fatalf("deliverMischief: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alive, floored, and carried home.
|
||||||
|
char, err := loadAdvCharacter(uid)
|
||||||
|
if err != nil || char == nil {
|
||||||
|
t.Fatal("target's character is gone")
|
||||||
|
}
|
||||||
|
if !char.Alive {
|
||||||
|
t.Fatal("a PURCHASED monster killed a player outright — mischief must maim, never murder")
|
||||||
|
}
|
||||||
|
if hp, _ := dndHPSnapshot(uid); hp != 1 {
|
||||||
|
t.Errorf("downed target is at %d HP, want the 1 HP floor", hp)
|
||||||
|
}
|
||||||
|
if e, _ := getActiveExpedition(uid); e != nil {
|
||||||
|
t.Error("the expedition survived a downing — it must be force-extracted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The buyer gets nothing back; the whole fee is a sink.
|
||||||
|
got, err := mischiefContractByID(c.ID)
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatal("contract row vanished")
|
||||||
|
}
|
||||||
|
if got.Status != mischiefStatusDelivered || got.Outcome != mischiefOutcomeDowned {
|
||||||
|
t.Errorf("contract resolved as %s/%s, want delivered/downed", got.Status, got.Outcome)
|
||||||
|
}
|
||||||
|
if pot := communityPotBalance(); pot < c.Paid {
|
||||||
|
t.Errorf("community pot holds %d, want at least the %d fee — a landed hit refunds nobody", pot, c.Paid)
|
||||||
|
}
|
||||||
|
if bal := p.euro.GetBalance(c.BuyerID); bal > 0 {
|
||||||
|
t.Errorf("buyer was credited %.0f on a successful hit; glory only", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Survival pays the target out of the buyer's money, and never more than the
|
||||||
|
// buyer spent. Driven through the real close-out, not the arithmetic helper.
|
||||||
|
func TestMischiefDelivery_SurvivalPaysTheTarget(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
uid := id.UserID("@tough:example.org")
|
||||||
|
exp := seedMischiefTarget(t, uid, 12, 400) // comfortably survives a tier-1 grunt
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:example.org", uid, "grunt", false)
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
if err := p.deliverMischief(c, exp); err != nil {
|
||||||
|
t.Fatalf("deliverMischief: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Outcome != mischiefOutcomeSurvived {
|
||||||
|
t.Fatalf("a level-12 fighter with 400 HP lost to a tier-1 grunt (outcome %s)", got.Outcome)
|
||||||
|
}
|
||||||
|
tier, _ := mischiefTierByKey("grunt")
|
||||||
|
purse := mischiefPurse(tier.Fee, tier.PayoutPct)
|
||||||
|
if gained := int(p.euro.GetBalance(uid) - before); gained != purse {
|
||||||
|
t.Errorf("survivor was paid %d, want the %d purse", gained, purse)
|
||||||
|
}
|
||||||
|
if purse >= c.Paid {
|
||||||
|
t.Errorf("purse %d >= outlay %d", purse, c.Paid)
|
||||||
|
}
|
||||||
|
// The remainder is a sink, not a rebate.
|
||||||
|
if pot := communityPotBalance(); pot != c.Paid-purse {
|
||||||
|
t.Errorf("pot holds %d, want the %d remainder", pot, c.Paid-purse)
|
||||||
|
}
|
||||||
|
// The expedition is untouched — they fought it off and walk on.
|
||||||
|
if e, _ := getActiveExpedition(uid); e == nil {
|
||||||
|
t.Error("surviving a contract ended the expedition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An expedition that ends before the window closes fizzles: the buyer is
|
||||||
|
// refunded 90%, the town rakes the rest, and nobody is attacked.
|
||||||
|
func TestMischiefDelivery_FizzlesWhenTargetWentHome(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
uid := id.UserID("@gone:example.org")
|
||||||
|
seedMischiefTarget(t, uid, 5, 60)
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:example.org", uid, "elite", false)
|
||||||
|
// They extract before the monster arrives.
|
||||||
|
if _, _, err := forcedExtractExpedition("exp-"+uid.Localpart(), "went home"); err != nil {
|
||||||
|
t.Fatalf("forcedExtractExpedition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.fireMischiefDeliveries(time.Now().UTC().Add(mischiefWindow + time.Minute))
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Status != mischiefStatusFizzled {
|
||||||
|
t.Fatalf("contract status %s, want fizzled — the dungeon was empty", got.Status)
|
||||||
|
}
|
||||||
|
refund := int(float64(c.Paid) * mischiefFizzleRefund)
|
||||||
|
if bal := int(p.euro.GetBalance(c.BuyerID)); bal != refund {
|
||||||
|
t.Errorf("buyer refunded %d, want %d (90%%)", bal, refund)
|
||||||
|
}
|
||||||
|
if pot := communityPotBalance(); pot != c.Paid-refund {
|
||||||
|
t.Errorf("pot raked %d, want %d", pot, c.Paid-refund)
|
||||||
|
}
|
||||||
|
if hp, max := dndHPSnapshot(uid); hp != max {
|
||||||
|
t.Errorf("a fizzled contract still hurt the target (%d/%d HP)", hp, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The limbo state. A leader can win a fight their friend went down in — and a
|
||||||
|
// mischief delivery deliberately skips the close-outs that would normally mark a
|
||||||
|
// 0-HP seat dead. So the seat must be floored on BOTH outcomes, or the member is
|
||||||
|
// left alive at 0 HP: not dead enough for the hospital, too dead to act, and
|
||||||
|
// read as broken by every gate that tests `HPCurrent <= 0`.
|
||||||
|
func TestMischiefDelivery_SurvivalFloorsADroppedPartyMember(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||||
|
leader := id.UserID("@leader:example.org")
|
||||||
|
member := id.UserID("@member:example.org")
|
||||||
|
|
||||||
|
exp := seedMischiefTarget(t, leader, 12, 400) // wins comfortably
|
||||||
|
seatLeaderFixture(t, exp.ID)
|
||||||
|
if err := createAdvCharacter(member, "member"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(&DnDCharacter{
|
||||||
|
UserID: member, Race: RaceHuman, Class: ClassFighter, Level: 3,
|
||||||
|
STR: 14, DEX: 12, CON: 12, INT: 10, WIS: 10, CHA: 10,
|
||||||
|
HPMax: 30, HPCurrent: 30, ArmorClass: 12,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := joinParty(exp.ID, member); err != nil {
|
||||||
|
t.Fatalf("joinParty: %v", err)
|
||||||
|
}
|
||||||
|
// Put the member on the floor the way the fight would.
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(member)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := seedContract(t, "@buyer:example.org", leader, "grunt", false)
|
||||||
|
if !claimMischiefForDelivery(c.ID) {
|
||||||
|
t.Fatal("claim should win")
|
||||||
|
}
|
||||||
|
if err := p.deliverMischief(c, exp); err != nil {
|
||||||
|
t.Fatalf("deliverMischief: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := mischiefContractByID(c.ID)
|
||||||
|
if got.Outcome != mischiefOutcomeSurvived {
|
||||||
|
t.Fatalf("leader lost; this test needs a survival (outcome %s)", got.Outcome)
|
||||||
|
}
|
||||||
|
hp, _ := dndHPSnapshot(member)
|
||||||
|
if hp <= 0 {
|
||||||
|
t.Errorf("dropped party member left at %d HP on a survived contract — alive, but stuck at zero", hp)
|
||||||
|
}
|
||||||
|
if mc, _ := loadAdvCharacter(member); mc == nil || !mc.Alive {
|
||||||
|
t.Error("a bought monster killed a party member — mischief maims, it never murders")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -695,7 +695,7 @@ func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBoss
|
|||||||
|
|
||||||
// No death / no hospital: floor the fighter at 1 HP so a loss never reads as
|
// No death / no hospital: floor the fighter at 1 HP so a loss never reads as
|
||||||
// a corpse. runZoneCombat already persisted the real HP cost.
|
// a corpse. runZoneCombat already persisted the real HP cost.
|
||||||
battered := worldBossFloorHP(userID)
|
battered := floorHPAtOne(userID)
|
||||||
|
|
||||||
dmg := result.EnemyEntryHP - result.EnemyEndHP
|
dmg := result.EnemyEntryHP - result.EnemyEndHP
|
||||||
if dmg < 0 {
|
if dmg < 0 {
|
||||||
@@ -718,10 +718,11 @@ func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBoss
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// worldBossFloorHP raises a player to 1 HP if the bout left them at zero, and
|
// floorHPAtOne raises a player to 1 HP if the fight left them at zero, and
|
||||||
// reports whether it had to. Keeps "no death" honest without undoing the real
|
// reports whether it had to. Keeps "no death" honest without undoing the real
|
||||||
// HP cost of a bout the player mostly survived.
|
// HP cost of a bout the player mostly survived. Shared by the two no-death
|
||||||
func worldBossFloorHP(userID id.UserID) bool {
|
// fights: the world-boss bout and a Mischief Makers delivery.
|
||||||
|
func floorHPAtOne(userID id.UserID) bool {
|
||||||
cur, _ := dndHPSnapshot(userID)
|
cur, _ := dndHPSnapshot(userID)
|
||||||
if cur > 0 {
|
if cur > 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -291,13 +291,13 @@ func TestWorldBossFloorHP(t *testing.T) {
|
|||||||
newWorldBossTestDB(t)
|
newWorldBossTestDB(t)
|
||||||
uid := id.UserID("@floor:test.invalid")
|
uid := id.UserID("@floor:test.invalid")
|
||||||
fightableChar(t, uid)
|
fightableChar(t, uid)
|
||||||
if worldBossFloorHP(uid) {
|
if floorHPAtOne(uid) {
|
||||||
t.Error("floor should be a no-op above 0 HP")
|
t.Error("floor should be a no-op above 0 HP")
|
||||||
}
|
}
|
||||||
if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
|
if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !worldBossFloorHP(uid) {
|
if !floorHPAtOne(uid) {
|
||||||
t.Error("floor should raise a 0-HP fighter")
|
t.Error("floor should raise a 0-HP fighter")
|
||||||
}
|
}
|
||||||
if cur, _ := dndHPSnapshot(uid); cur != 1 {
|
if cur, _ := dndHPSnapshot(uid); cur != 1 {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ var advActionCommands = map[string]bool{
|
|||||||
"explore": true, "extract": true, "fight": true, "fish": true,
|
"explore": true, "extract": true, "fight": true, "fish": true,
|
||||||
"flee": true, "forage": true, "give": true, "graveyard": true,
|
"flee": true, "forage": true, "give": true, "graveyard": true,
|
||||||
"hospital": true, "level": true, "lore": true, "map": true,
|
"hospital": true, "level": true, "lore": true, "map": true,
|
||||||
"mine": true, "news": true, "prepare": true, "region": true,
|
"mine": true, "mischief": true, "news": true, "prepare": true, "region": true,
|
||||||
"resources": true, "respec": true, "rest": true, "resume": true,
|
"resources": true, "respec": true, "rest": true, "resume": true,
|
||||||
"revisit": true, "rivals": true, "roll": true, "scavenge": true,
|
"revisit": true, "rivals": true, "roll": true, "scavenge": true,
|
||||||
"sell": true, "setup": true, "sheet": true, "spells": true,
|
"sell": true, "setup": true, "sheet": true, "spells": true,
|
||||||
|
|||||||
85
internal/plugin/mischief_pricing_sweep_test.go
Normal file
85
internal/plugin/mischief_pricing_sweep_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// M0 pricing sweep for gogobee_mischief_plan.md — single-fight survival per
|
||||||
|
// mischief tier, measured against the class-balance harness builds. Skip-gated:
|
||||||
|
// it is a pricing instrument, not a regression test.
|
||||||
|
//
|
||||||
|
// It drives the SAME production selection code a live delivery does
|
||||||
|
// (mischiefBracketZone + mischiefMonsters), so the fee table can't silently
|
||||||
|
// drift away from the fight it was priced against. What it does NOT share is the
|
||||||
|
// delivery path itself: this measures a full-HP build in a single chain, which
|
||||||
|
// is optimistic — a real target is wounded mid-run, and may have a party. The
|
||||||
|
// plan's M1 close-out item is to re-run this through runMischiefInterrupt.
|
||||||
|
//
|
||||||
|
// Run: MISCHIEF_SWEEP=1 go test ./internal/plugin -run TestMischiefPricingSweep -v
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// one trial: full-HP build fights the tier's chain; survival = won every fight.
|
||||||
|
func mischiefTrial(p classBalanceProfile, tierKey string, rng *rand.Rand) (survived bool, endHPPct float64) {
|
||||||
|
c := buildHarnessCharacter(p)
|
||||||
|
zone := mischiefBracketZone(p.Level)
|
||||||
|
chain, ambush := mischiefMonsters(tierKey, zone, rng)
|
||||||
|
hp := c.HPMax
|
||||||
|
for i, m := range chain {
|
||||||
|
if ambush && i == 0 {
|
||||||
|
hp -= clampSurpriseNick(surpriseRoundNick(m, int(zone.Tier)), hp, c.HPMax)
|
||||||
|
}
|
||||||
|
player := buildHarnessPlayer(c)
|
||||||
|
if hp < c.HPMax {
|
||||||
|
player.Stats.StartHP = hp
|
||||||
|
}
|
||||||
|
enemy := buildHarnessZoneEnemy(m, int(zone.Tier))
|
||||||
|
if spell, slot, ok := pickBestDamageSpell(c); ok {
|
||||||
|
applyHarnessSpellCast(c, spell, slot, &player.Stats, &player.Mods, &enemy.Stats)
|
||||||
|
}
|
||||||
|
res := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||||
|
if !res.PlayerWon {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
hp = res.PlayerEndHP
|
||||||
|
}
|
||||||
|
return true, float64(hp) / float64(c.HPMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefPricingSweep(t *testing.T) {
|
||||||
|
if os.Getenv("MISCHIEF_SWEEP") == "" {
|
||||||
|
t.Skip("set MISCHIEF_SWEEP=1 to run")
|
||||||
|
}
|
||||||
|
const trials = 2000
|
||||||
|
rng := rand.New(rand.NewPCG(20260713, 0x6D69736368696566))
|
||||||
|
profiles := []classBalanceProfile{
|
||||||
|
{Class: ClassPaladin, Level: 1},
|
||||||
|
{Class: ClassMage, Level: 4},
|
||||||
|
{Class: ClassMage, Level: 8, Subclass: SubclassEvocation},
|
||||||
|
{Class: ClassFighter, Level: 12, Subclass: SubclassChampion},
|
||||||
|
{Class: ClassRogue, Level: 20, Subclass: SubclassArcaneTrickster},
|
||||||
|
{Class: ClassCleric, Level: 20, Subclass: SubclassLifeDomain},
|
||||||
|
}
|
||||||
|
fmt.Printf("%-28s %-8s %8s %10s\n", "profile", "tier", "survive%", "avgEndHP%")
|
||||||
|
for _, p := range profiles {
|
||||||
|
for _, tier := range mischiefTiers {
|
||||||
|
wins := 0
|
||||||
|
hpSum := 0.0
|
||||||
|
for i := 0; i < trials; i++ {
|
||||||
|
ok, hpPct := mischiefTrial(p, tier.Key, rng)
|
||||||
|
if ok {
|
||||||
|
wins++
|
||||||
|
hpSum += hpPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avgHP := 0.0
|
||||||
|
if wins > 0 {
|
||||||
|
avgHP = hpSum / float64(wins) * 100
|
||||||
|
}
|
||||||
|
fmt.Printf("%-28s %-8s %7.1f%% %9.1f%%\n",
|
||||||
|
fmt.Sprintf("%s L%d %s", p.Class, p.Level, p.Subclass), tier.Key,
|
||||||
|
float64(wins)/trials*100, avgHP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user