From 401b17c3bba75e9b81bc04d51acc88fb0fd34b6b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:18:45 -0700 Subject: [PATCH] =?UTF-8?q?N6/C2:=20player-initiated=20duels=20=E2=80=94?= =?UTF-8?q?=20staked,=20no-death=20PvP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `!duel @user [stake]` (+ `!adventure duel`; accept/decline/status). A staked arena bout resolved through the auto-resolve combat engine. The engine is asymmetric — N player-seats vs one monster-shaped enemy, no seat for a second PC — so a duel builds each fighter as their real player Combatant and synthesises the opponent as an enemy stat block from their sheet. That is lopsided (player side = full kit, enemy = flat stat block), so the bout runs BOTH orientations and decides on aggregate remaining-HP fraction, cancelling the attacker-seat edge. To-hit stays faithful both ways (d20 + AttackBonus vs AC); damage folds into one enemy Attack (per-hit × swings + companion-proc expectation); DamageReduct ports over. Casters fight weapon-only in both orientations — accepted, PvP class balance is out of scope per the plan. Economics: both escrow the stake on accept (pool 2×stake); winner 70%, community pot rakes 30% (a sink); exact-HP-tie draw refunds both. Stake capped at level×€500. W/L reuses adventure_rival_records; shared 7-day pair cooldown. Terminal fate is serialised by an atomic claim (DELETE … WHERE id=? gated on RowsAffected==1, mirroring communityPotDebit): accept/decline/the expiry ticker all claim first, only the winner moves euros — no stake can be both resolved and refunded across the 24h boundary. issueDuel runs under advUserLock; accept re-checks the challenger and builds both fighters before debiting the challenged. Rides the 1-min eventTicker; combat golden byte-identical; suite green. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa --- internal/db/db.go | 14 + internal/plugin/adventure.go | 7 + internal/plugin/adventure_duel.go | 701 +++++++++++++++++++++++++ internal/plugin/adventure_duel_test.go | 274 ++++++++++ internal/plugin/adventure_events.go | 4 + 5 files changed, 1000 insertions(+) create mode 100644 internal/plugin/adventure_duel.go create mode 100644 internal/plugin/adventure_duel_test.go diff --git a/internal/db/db.go b/internal/db/db.go index eb3ee28..f7be0a7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1610,6 +1610,20 @@ CREATE TABLE IF NOT EXISTS adventure_rival_challenges ( ); CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at); +-- Duels (N6/C2): player-initiated, staked, no-death combat bouts. Both stakes +-- are escrowed while the row lives; expiry/decline refunds the challenger. W/L +-- history reuses adventure_rival_records. No bootstrap — absent row == no duel. +CREATE TABLE IF NOT EXISTS adventure_duel_challenges ( + challenge_id TEXT PRIMARY KEY, + challenger_id TEXT NOT NULL, + challenged_id TEXT NOT NULL, + stake INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_duel_challenges_user ON adventure_duel_challenges(challenged_id, expires_at); +CREATE INDEX IF NOT EXISTS idx_duel_challenges_expiry ON adventure_duel_challenges(expires_at); + CREATE TABLE IF NOT EXISTS community_pot ( id INTEGER PRIMARY KEY DEFAULT 1, balance INTEGER NOT NULL DEFAULT 0, diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 00c5b32..3541582 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -169,6 +169,7 @@ func (p *AdventurePlugin) Commands() []CommandDef { {Name: "rest", Description: "Rest up (`short`: quick breather, 1h cooldown — `long`: full night, 24h, needs housing or inn)", Usage: "!rest short|long", Category: "Games"}, {Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm ", Category: "Games"}, {Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll ", Category: "Games"}, + {Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"}, {Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"}, {Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"}, {Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast [--upcast N] [--target @user]", Category: "Games"}, @@ -337,6 +338,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "rest") { return p.handleDnDRestCmd(ctx, p.GetArgs(ctx.Body, "rest")) } + if p.IsCommand(ctx.Body, "duel") { + return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel")) + } if p.IsCommand(ctx.Body, "arm") { return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm")) } @@ -513,6 +517,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.SendDM(ctx.Sender, advHelpText) case lower == "rivals": return p.handleRivalsCmd(ctx) + case lower == "duel" || strings.HasPrefix(lower, "duel "): + return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):])) case lower == "babysit" || strings.HasPrefix(lower, "babysit "): return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit"))) case lower == "blacksmith" || lower == "repair": @@ -562,6 +568,7 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure leaderboard`" + ` — View the leaderboard ` + "`!adventure respond`" + ` — Respond to a mid-day event ` + "`!adventure rivals`" + ` — View rival duel records +` + "`!duel @user [stake]`" + ` — Challenge another adventurer to a staked, no-death bout (` + "`!duel accept`" + `/` + "`decline`" + `/` + "`status`" + `) ` + "`!adventure babysit`" + ` — Adventurer Babysitting Service ` + "`!adventure babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days) ` + "`!adventure babysit focus `" + ` — Pick the skill auto-babysit trains (mining/fishing/foraging) diff --git a/internal/plugin/adventure_duel.go b/internal/plugin/adventure_duel.go new file mode 100644 index 0000000..5ddc7fe --- /dev/null +++ b/internal/plugin/adventure_duel.go @@ -0,0 +1,701 @@ +package plugin + +// C2 — player-initiated duels (engagement plan N6). +// +// `!duel @user [stake]` — a staked, no-death arena bout between two real +// characters, resolved through the auto-resolve combat engine. Both players +// escrow the stake on accept; the winner takes 70% of the pooled 2×stake and +// the community pot rakes the remaining 30% (a real pot sink). Records land in +// adventure_rival_records, so one W/L history and one 7-day per-pair cooldown +// covers both duel kinds (the bot-driven RPS rival and this). +// +// Why this is a wrapper and not a combat-engine change: the turn engine is an +// N-players-vs-1-monster model — there is no seat for a second real character +// on the enemy side (see the C2 feasibility note in the engagement plan). So a +// duel does not seat two PCs against each other. Instead each fighter is built +// as their real player Combatant (full kit: weapon, subclass passives, extra +// attacks, race traits) and the *opponent* is synthesised as a monster-shaped +// enemy stat block from their own sheet. That is asymmetric — the "player" side +// runs its full kit while the synthesised enemy is a flat stat block — so a +// single bout would systematically favour whoever is the attacker. The duel +// runs the bout in BOTH orientations and decides on the aggregate HP margin, +// which cancels that edge symmetrically. To-hit is faithful either way (both +// sides roll d20 + AttackBonus vs AC); only damage-per-hit is folded into the +// synthesised enemy's flat Attack. Class-vs-class PvP balance is explicitly out +// of scope (bragging rights, capped stakes). + +import ( + "fmt" + "math" + "math/rand/v2" + "strings" + "time" + + "gogobee/internal/db" + + "github.com/google/uuid" + "maunium.net/go/mautrix/id" +) + +const ( + duelMinLevel = 3 // both duellists must be at least this level + duelAcceptWindow = 24 * time.Hour // an unanswered challenge expires and refunds + duelStakePerLvl = 500 // stake cap = challenger level × this + duelMinStake = 100 // floor so a "duel" always has something on it + duelWinnerPct = 0.70 // winner's cut of the pooled 2×stake; pot rakes the rest +) + +// advDuelChallenge is one outstanding duel invitation. Unlike the RPS rival +// challenge it carries no per-round score — a combat duel resolves in a single +// step on accept. The challenger's stake is already escrowed (debited) when the +// row exists; the challenged player's is debited on accept. +type advDuelChallenge struct { + ChallengeID string + ChallengerID id.UserID + ChallengedID id.UserID + Stake int + ExpiresAt time.Time + CreatedAt time.Time +} + +// ── Persistence ────────────────────────────────────────────────────────────── + +func insertDuelChallenge(c *advDuelChallenge) { + db.Exec("duel: insert challenge", + `INSERT INTO adventure_duel_challenges + (challenge_id, challenger_id, challenged_id, stake, expires_at) + VALUES (?, ?, ?, ?, ?)`, + c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID), c.Stake, c.ExpiresAt, + ) +} + +func deleteDuelChallenge(challengeID string) { + db.Exec("duel: delete challenge", + `DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID) +} + +// claimDuelChallenge atomically deletes the row and reports whether THIS caller +// is the one that removed it. It is the single serialization point for a +// challenge's terminal fate: accept, decline and the expiry ticker all race to +// claim it, and exactly one wins. Only the winner moves euros — that is what +// stops a stake being both resolved and refunded across the 24h boundary, or a +// double `!duel decline` double-refunding. Mirrors communityPotDebit's +// RowsAffected gate. +func claimDuelChallenge(challengeID string) bool { + res := db.ExecResult("duel: claim challenge", + `DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +func scanDuelChallenge(row interface{ Scan(...any) error }) (*advDuelChallenge, error) { + c := &advDuelChallenge{} + err := row.Scan(&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt) + if err != nil { + return nil, err + } + return c, nil +} + +// pendingDuelForChallenged returns the live (unexpired) challenge awaiting this +// player's answer, or nil. Expired rows are ignored here and swept by the +// ticker, so a player is never shown a challenge they can no longer accept. +func pendingDuelForChallenged(userID id.UserID) *advDuelChallenge { + c := &advDuelChallenge{} + err := db.Get().QueryRow(` + SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at + FROM adventure_duel_challenges + WHERE challenged_id = ? AND expires_at > CURRENT_TIMESTAMP + ORDER BY created_at DESC LIMIT 1`, string(userID)).Scan( + &c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt) + if err != nil { + return nil + } + return c +} + +// duelInvolves reports whether the player has any live challenge in flight, as +// challenger or challenged. Used to enforce one duel at a time per player — the +// escrow makes overlapping challenges a way to double-commit the same euros. +func duelInvolves(userID id.UserID) bool { + var n int + db.Get().QueryRow(` + SELECT COUNT(*) FROM adventure_duel_challenges + WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`, + string(userID), string(userID)).Scan(&n) + return n > 0 +} + +func loadExpiredDuelChallenges() []advDuelChallenge { + rows, err := db.Get().Query(` + SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at + FROM adventure_duel_challenges WHERE expires_at <= CURRENT_TIMESTAMP`) + if err != nil { + return nil + } + defer rows.Close() + var out []advDuelChallenge + for rows.Next() { + if c, err := scanDuelChallenge(rows); err == nil { + out = append(out, *c) + } + } + return out +} + +// ── Stake / gating ─────────────────────────────────────────────────────────── + +// duelMaxStake caps the stake at level × duelStakePerLvl so a strong player +// can't farm a runaway pot off a weaker one, per the plan's imbalance cap. +func duelMaxStake(level int) int { return level * duelStakePerLvl } + +// duelPayout splits the pooled 2×stake: the winner takes duelWinnerPct, the +// community pot rakes the remainder. Both fighters escrowed stake, so the +// winner's net gain is winnerShare − stake and the pot is a real sink. +func duelPayout(stake int) (winnerShare, potShare int) { + pool := stake * 2 + winnerShare = int(math.Round(float64(pool) * duelWinnerPct)) + potShare = pool - winnerShare + return +} + +// duelEligible reports whether a character can duel at all: alive, set up, and +// at least duelMinLevel. Returns a player-facing reason when it can't. +func (p *AdventurePlugin) duelEligible(char *AdventureCharacter) (int, string) { + if char == nil { + return 0, "no adventurer" + } + if !char.Alive { + return 0, "dead" + } + lvl := rivalLevelForUser(char) + if lvl < duelMinLevel { + return lvl, fmt.Sprintf("below level %d", duelMinLevel) + } + return lvl, "" +} + +// ── Combat synthesis ───────────────────────────────────────────────────────── + +// buildDuelist derives a fighter's full player Combatant — the same build a +// zone encounter uses, so weapon, subclass passives, extra attacks and race +// traits all apply — then resets it to full health. A duel is a fresh arena +// bout, not a continuation of a wounded expedition, so wound carry-over is +// dropped (StartHP=0 = "enter at MaxHP"). Mood 50 is the neutral band: no +// initiative bias, no enemy-attack tilt. +// +// The armed-ability slot is left empty: actively-armed one-shots (a Berserker's +// rage) don't fire, but always-on passives (Extra Attack, Sneak Attack, Divine +// Strike, race traits) still layer in via the class/subclass passive builders. +func (p *AdventurePlugin) buildDuelist(userID id.UserID) (Combatant, error) { + var noMonster DnDMonsterTemplate + player, _, _, err := p.buildZoneCombatants(userID, noMonster, 1, 50, "") + if err != nil { + return Combatant{}, err + } + player.Stats.StartHP = 0 + return player, nil +} + +// duelPerHitDamage is a fighter's expected damage on a single landed hit, used +// to size the flat Attack of their synthesised enemy stat block. It mirrors the +// player damage path's terms that the enemy path can't express: weapon dice +// average (or the legacy flat Attack for a weaponless build), the always-on +// per-hit riders (Divine Strike, Sneak Attack, Hunter's Mark, rage), then the +// multiplicative damage bonus. Crit chance and once-per-fight openers are not +// modelled — a coarse mean is enough for a capped-stakes bout decided over two +// orientations. +func duelPerHitDamage(pc Combatant) float64 { + base := float64(pc.Stats.Attack) + if w := pc.Stats.Weapon; w != nil { + if pc.Stats.TwoHandedMode && w.HasProperty(PropVersatile) && w.VersaCount > 0 { + base = float64(w.VersaCount)*(float64(w.VersaSides)+1)/2 + + float64(pc.Stats.AbilityModForDamage) + float64(w.MagicBonus) + } else { + base = avgWeaponDamage(w, pc.Stats.AbilityModForDamage) + } + } + riders := float64(pc.Mods.DivineStrikePerHit) + riders += float64(pc.Mods.SneakAttackDie) * 3.5 + riders += float64(pc.Mods.HuntersMarkDie) * 3.5 + if pc.Mods.BerserkerRage { + riders += float64(pc.Mods.RageMeleeDmg) + } + perHit := (base + riders) * (1 + pc.Mods.DamageBonus) + if perHit < 1 { + perHit = 1 + } + return perHit +} + +// duelPerRoundProcDamage is the expected per-round damage from a fighter's +// companion strikes — a Cleric's spiritual weapon and a Beastmaster's pet — +// which land once per round on a proc roll rather than per weapon hit. The live +// fighter rolls these natively in the engine; folding their expectation into the +// synth enemy's round damage keeps both orientations comparable for those +// builds (each formula is Dmg + d5, mean +3). +func duelPerRoundProcDamage(pc Combatant) float64 { + dmg := pc.Mods.SpiritWeaponProc * (float64(pc.Mods.SpiritWeaponDmg) + 3) + dmg += pc.Mods.PetAttackProc * (float64(pc.Mods.PetAttackDmg) + 3) + return dmg +} + +// synthDuelEnemy turns a fighter's player Combatant into the monster-shaped +// enemy the opposite fighter faces. HP, AC and AttackBonus carry over verbatim +// (to-hit stays faithful — the enemy rolls d20 + AttackBonus vs the attacker's +// AC exactly as the player does). DamageReduct carries over too, so a +// Barbarian's / Monk's flat mitigation is as tanky as the enemy as it is as the +// live PC. Offense is the lossy term: the enemy path deals a flat Attack, so a +// whole round — weapon swings (per-hit × (1 + extra attacks)) plus the expected +// companion-strike damage — is folded into one enemy Attack, preserving expected +// round damage while widening variance (a wash across the two orientations). +// +// Deliberately NOT modelled on the enemy side (accepted scope — the plan frames +// duels as bragging rights and puts class-vs-class PvP balance explicitly out of +// scope): reactive one-shots the enemy engine has no channel for — heal items, +// wards, the Sovereign death-save, the arcane ward buffer — and spell damage, +// which buildZoneCombatants never wires for the live caster either, so a caster +// fights weapon-only in both orientations. Defense is dropped: the weapon-damage +// path players use reads DamageReduct, not the enemy's Defense stat. +func synthDuelEnemy(pc Combatant) Combatant { + swings := 1 + pc.Mods.ExtraAttacks + roundDmg := duelPerHitDamage(pc)*float64(swings) + duelPerRoundProcDamage(pc) + return Combatant{ + Name: pc.Name, + IsPlayer: false, + Stats: CombatStats{ + MaxHP: pc.Stats.MaxHP, + AC: pc.Stats.AC, + AttackBonus: pc.Stats.AttackBonus, + Attack: int(math.Round(roundDmg)), + }, + Mods: CombatModifiers{DamageReduct: pc.Mods.DamageReduct}, + } +} + +// duelResult is the two-orientation verdict. challengerMargin/challengedMargin +// are aggregate remaining-HP fractions across both bouts (0..2 each); the higher +// wins. decisive is true when one fighter won both orientations outright — pure +// narration, the margin is what decides. +type duelResult struct { + challengerWins bool + draw bool + decisive bool + challengerFrac float64 + challengedFrac float64 +} + +func hpFraction(end, start int) float64 { + if start <= 0 { + return 0 + } + if end < 0 { + end = 0 + } + return float64(end) / float64(start) +} + +// resolveDuel runs the bout both ways and decides on the aggregate HP margin. +// Orientation 1 seats the challenger as the live fighter against the challenged +// player's stat block; orientation 2 swaps them. Each fighter's margin sums the +// HP fraction they kept as the live fighter in one bout and as the stat block in +// the other, so the attacker's-kit advantage is applied to both fighters once +// and cancels. rng is threaded for the seeded characterization/unit tests; nil +// is production (package-global RNG). +func resolveDuel(challenger, challenged Combatant, rng *rand.Rand) duelResult { + r1 := simulateCombatWithRNG(challenger, synthDuelEnemy(challenged), defaultCombatPhases, rng) + r2 := simulateCombatWithRNG(challenged, synthDuelEnemy(challenger), defaultCombatPhases, rng) + + cFrac := hpFraction(r1.PlayerEndHP, r1.PlayerStartHP) + hpFraction(r2.EnemyEndHP, r2.EnemyStartHP) + dFrac := hpFraction(r1.EnemyEndHP, r1.EnemyStartHP) + hpFraction(r2.PlayerEndHP, r2.PlayerStartHP) + + res := duelResult{challengerFrac: cFrac, challengedFrac: dFrac} + switch { + case cFrac > dFrac: + res.challengerWins = true + case dFrac > cFrac: + res.challengerWins = false + default: + res.draw = true + } + // Decisive = the winner also took both orientations outright. + if !res.draw { + if res.challengerWins { + res.decisive = r1.PlayerWon && !r2.PlayerWon + } else { + res.decisive = r2.PlayerWon && !r1.PlayerWon + } + } + return res +} + +// ── Command surface ────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) handleDuelCmd(ctx MessageContext, args string) error { + args = strings.TrimSpace(args) + fields := strings.Fields(args) + if len(fields) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, + "⚔️ **Duels** — challenge another adventurer to a staked, no-death bout.\n"+ + "`!duel @user [stake]` — throw down · `!duel accept` / `!duel decline` · `!duel status`") + } + switch strings.ToLower(fields[0]) { + case "accept": + return p.acceptDuel(ctx) + case "decline": + return p.declineDuel(ctx) + case "status": + return p.duelStatusCmd(ctx) + default: + return p.issueDuel(ctx, fields) + } +} + +func (p *AdventurePlugin) issueDuel(ctx MessageContext, fields []string) error { + targetID, ok := p.ResolveUser(fields[0], ctx.RoomID) + if !ok { + return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve that user. Usage: `!duel @user [stake]`") + } + if targetID == ctx.Sender { + return p.SendReply(ctx.RoomID, ctx.EventID, "You can't duel yourself.") + } + + // Serialise the one-duel gate, balance check and escrow so two rapid `!duel` + // from the same challenger can't both pass duelInvolves and double-debit. + lock := p.advUserLock(ctx.Sender) + lock.Lock() + defer lock.Unlock() + + challenger, err := loadAdvCharacter(ctx.Sender) + if err != nil || challenger == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer. Type `!adventure` to create one.") + } + lvl, reason := p.duelEligible(challenger) + if reason != "" { + return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason)) + } + + challenged, err := loadAdvCharacter(targetID) + if err != nil || challenged == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no adventurer to duel.", p.DisplayName(targetID))) + } + if _, reason := p.duelEligible(challenged); reason != "" { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("%s can't duel right now (%s).", p.DisplayName(targetID), reason)) + } + if challenged.BabysitActive { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("%s left word with the Babysitting Service — no duels while they're away.", p.DisplayName(targetID))) + } + + // One duel at a time per player, either side — the escrow makes overlaps a + // double-commit of the same euros. + if duelInvolves(ctx.Sender) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You already have a duel in flight. Resolve it first.") + } + if duelInvolves(targetID) { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s already has a duel in flight.", p.DisplayName(targetID))) + } + + // Shared 7-day per-pair cooldown across both duel kinds (records table). + if last := lastDuelBetween(ctx.Sender, targetID); !last.IsZero() && time.Since(last) < rivalSamePairCooldown { + wait := rivalSamePairCooldown - time.Since(last) + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("You've dueled %s too recently. Try again %s.", p.DisplayName(targetID), formatDuration(wait))) + } + + stake, err := p.parseDuelStake(fields, lvl) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, err.Error()) + } + if bal := p.euro.GetBalance(ctx.Sender); int(bal) < stake { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("You can't cover a €%d stake — you have €%d.", stake, int(bal))) + } + + // Escrow the challenger's stake now (debited to nowhere; the pot/winner split + // redistributes it on resolution, and expiry/decline refunds it). + if !p.euro.Debit(ctx.Sender, float64(stake), "duel_escrow") { + return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. Try again.") + } + + ch := &advDuelChallenge{ + ChallengeID: uuid.NewString(), + ChallengerID: ctx.Sender, + ChallengedID: targetID, + Stake: stake, + ExpiresAt: time.Now().UTC().Add(duelAcceptWindow), + } + insertDuelChallenge(ch) + + p.SendDM(targetID, fmt.Sprintf( + "⚔️ **%s challenges you to a duel!**\n\nStake: €%d each — winner takes 70%% of the pot.\n"+ + "No death, no hospital bill. Just bragging rights.\n\n`!duel accept` or `!duel decline` — you have 24 hours.", + p.DisplayName(ctx.Sender), stake)) + + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "⚔️ You throw down the gauntlet before **%s** for €%d. They have 24 hours to answer.", + p.DisplayName(targetID), stake)) +} + +// parseDuelStake reads the optional stake argument, defaulting to the cap for +// the challenger's level and clamping into [duelMinStake, duelMaxStake]. +func (p *AdventurePlugin) parseDuelStake(fields []string, level int) (int, error) { + maxStake := duelMaxStake(level) + if len(fields) < 2 { + return maxStake, nil + } + raw := strings.TrimPrefix(fields[1], "€") + stake := 0 + if _, err := fmt.Sscanf(raw, "%d", &stake); err != nil || stake <= 0 { + return 0, fmt.Errorf("that's not a valid stake. Usage: `!duel @user [stake]`") + } + if stake < duelMinStake { + return 0, fmt.Errorf("minimum stake is €%d", duelMinStake) + } + if stake > maxStake { + return 0, fmt.Errorf("your stake is capped at €%d (level %d × €%d)", maxStake, level, duelStakePerLvl) + } + return stake, nil +} + +func (p *AdventurePlugin) acceptDuel(ctx MessageContext) error { + // Serialise a player's own accept so a double-submit can't debit twice. + lock := p.advUserLock(ctx.Sender) + lock.Lock() + defer lock.Unlock() + + ch := pendingDuelForChallenged(ctx.Sender) + if ch == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to accept.") + } + // Cheap validation first — these leave the challenge open (the player can + // revive/earn and retry, or decline to release the challenger's escrow). + challenged, err := loadAdvCharacter(ctx.Sender) + if err != nil || challenged == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer.") + } + if _, reason := p.duelEligible(challenged); reason != "" { + return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason)) + } + if bal := p.euro.GetBalance(ctx.Sender); int(bal) < ch.Stake { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("You can't cover the €%d stake — you have €%d. `!duel decline` to back out.", ch.Stake, int(bal))) + } + + // The challenger may have died or dropped below the gate during the 24h + // window. If so, cancel the duel: claim the row (so the ticker/decline can't + // also refund) and hand the challenger their escrow back. + challenger, cerr := loadAdvCharacter(ch.ChallengerID) + if cerr != nil || challenger == nil { + if claimDuelChallenge(ch.ChallengeID) { + p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_gone") + } + return p.SendReply(ctx.RoomID, ctx.EventID, "Your challenger is no longer available. The duel is off.") + } + if _, reason := p.duelEligible(challenger); reason != "" { + if claimDuelChallenge(ch.ChallengeID) { + p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_ineligible") + p.SendDM(ch.ChallengerID, fmt.Sprintf( + "⚔️ Your duel with **%s** was called off — you can't fight right now. Your €%d stake is refunded.", + p.DisplayName(ctx.Sender), ch.Stake)) + } + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("**%s** can't duel right now (%s). The challenge is off.", p.DisplayName(ch.ChallengerID), reason)) + } + + // Build both fighters BEFORE debiting the challenged player, so the deep + // sheet-loading / combat code (the panic-prone part) runs while nobody but + // the challenger is committed. A build failure refunds only the challenger. + challengerC, err1 := p.buildDuelist(ch.ChallengerID) + challengedC, err2 := p.buildDuelist(ctx.Sender) + if err1 != nil || err2 != nil { + if claimDuelChallenge(ch.ChallengeID) { + p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error") + } + return p.SendReply(ctx.RoomID, ctx.EventID, "The duel couldn't be staged. The challenger's stake is refunded.") + } + + // Commit: claim the row (exactly one path wins), then take the challenged + // player's stake. From here both stakes are held and resolution is pure + // arithmetic — no further build or DB read that could panic mid-payout. + if !claimDuelChallenge(ch.ChallengeID) { + return p.SendReply(ctx.RoomID, ctx.EventID, "That duel is no longer available.") + } + if !p.euro.Debit(ctx.Sender, float64(ch.Stake), "duel_escrow") { + p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error") + return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. The duel is off.") + } + + return p.settleDuel(ctx, ch, challengerC, challengedC) +} + +func (p *AdventurePlugin) declineDuel(ctx MessageContext) error { + // Same lock as accept so a player's own accept/decline can't interleave; the + // claim is the real guard, but the lock keeps the read→claim window tight. + lock := p.advUserLock(ctx.Sender) + lock.Lock() + defer lock.Unlock() + + ch := pendingDuelForChallenged(ctx.Sender) + if ch == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.") + } + if !claimDuelChallenge(ch.ChallengeID) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.") + } + p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_declined") + p.SendDM(ch.ChallengerID, fmt.Sprintf( + "🛡️ **%s** declined your duel. Your €%d stake is refunded.", p.DisplayName(ctx.Sender), ch.Stake)) + return p.SendReply(ctx.RoomID, ctx.EventID, "You decline the duel. No harm done.") +} + +func (p *AdventurePlugin) duelStatusCmd(ctx MessageContext) error { + if ch := pendingDuelForChallenged(ctx.Sender); ch != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "⚔️ **%s** has challenged you for €%d. `!duel accept` / `!duel decline` (expires %s).", + p.DisplayName(ch.ChallengerID), ch.Stake, formatDuration(time.Until(ch.ExpiresAt)))) + } + // Any challenge we issued that's still pending? + c := &advDuelChallenge{} + err := db.Get().QueryRow(` + SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at + FROM adventure_duel_challenges + WHERE challenger_id = ? AND expires_at > CURRENT_TIMESTAMP + ORDER BY created_at DESC LIMIT 1`, string(ctx.Sender)).Scan( + &c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt) + if err == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf( + "⚔️ You've challenged **%s** for €%d. Waiting on their answer (expires %s).", + p.DisplayName(c.ChallengedID), c.Stake, formatDuration(time.Until(c.ExpiresAt)))) + } + return p.SendReply(ctx.RoomID, ctx.EventID, "You have no duels in flight. `!duel @user [stake]` to start one.") +} + +// ── Resolution ─────────────────────────────────────────────────────────────── + +// settleDuel resolves the two-orientation bout, splits the pooled 2×stake, +// records the result, and DMs both players. By the time it runs the challenge +// row is claimed, both fighters are built, and both stakes are held — so from +// here it is pure arithmetic and message sends, nothing that can panic and +// strand the pool. On an exact HP-margin tie both stakes are refunded. +func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, challengerC, challengedC Combatant) error { + res := resolveDuel(challengerC, challengedC, nil) + + if res.draw { + // Exact HP-margin tie: refund both, record nothing, no cooldown burned. + p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_draw") + p.euro.Credit(ch.ChallengedID, float64(ch.Stake), "duel_refund_draw") + draw := fmt.Sprintf("⚔️ **%s** and **%s** fought to a standstill. Both stakes refunded.", + p.DisplayName(ch.ChallengerID), p.DisplayName(ch.ChallengedID)) + p.SendDM(ch.ChallengerID, draw) + p.SendDM(ch.ChallengedID, draw) + return p.SendReply(ctx.RoomID, ctx.EventID, draw) + } + + winnerID, loserID := ch.ChallengedID, ch.ChallengerID + if res.challengerWins { + winnerID, loserID = ch.ChallengerID, ch.ChallengedID + } + + winnerShare, potShare := duelPayout(ch.Stake) + p.euro.Credit(winnerID, float64(winnerShare), "duel_win") + if potShare > 0 { + communityPotAdd(potShare) + // The rake came out of a pool both funded; attribute it half each. + trackTaxPaid(winnerID, potShare/2) + trackTaxPaid(loserID, potShare-potShare/2) + } + + upsertRivalRecord(winnerID, loserID, true) + upsertRivalRecord(loserID, winnerID, false) + + p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive) + return nil +} + +func (p *AdventurePlugin) announceDuel(ctx MessageContext, winnerID, loserID id.UserID, stake, winnerShare, potShare int, decisive bool) { + winName, loseName := p.DisplayName(winnerID), p.DisplayName(loserID) + flair := pickDuelFlavor(duelWinFlavor) + if decisive { + flair = pickDuelFlavor(duelDecisiveFlavor) + } + + p.SendDM(winnerID, fmt.Sprintf( + "⚔️ **You beat %s!**\n\n*%s*\n\nYou take €%d (net +€%d). €%d went to the community pot.", + loseName, flair, winnerShare, winnerShare-stake, potShare)) + p.SendDM(loserID, fmt.Sprintf( + "⚔️ **%s bested you.**\n\n*%s*\n\nYou're down €%d — but you walk away, no worse for wear.", + winName, pickDuelFlavor(duelLossFlavor), stake)) + + // Broadcast the result to the games room for bragging rights — unless the + // duel was accepted there, in which case the SendReply below already covers + // it and a second line would just be noise. + if gr := gamesRoom(); gr != "" && gr != ctx.RoomID { + verb := "defeated" + if decisive { + verb = "decisively defeated" + } + p.SendMessage(gr, fmt.Sprintf("⚔️ **%s** %s **%s** in a duel for €%d.", winName, verb, loseName, stake)) + } + // Confirm in the room the challenge was accepted from too. + p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("⚔️ **%s** wins the duel, taking €%d.", winName, winnerShare)) +} + +// expireDuelChallenges refunds and clears any challenge whose 24h window lapsed. +// Rides the 1-minute event ticker (no new goroutine); only the challenger was +// escrowed, so only they are refunded. +func (p *AdventurePlugin) expireDuelChallenges() { + for _, ch := range loadExpiredDuelChallenges() { + // Claim before refunding: if an accept/decline already took this row at + // the 24h boundary, we must not also refund the challenger. + if !claimDuelChallenge(ch.ChallengeID) { + continue + } + p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_expired") + p.SendDM(ch.ChallengerID, fmt.Sprintf( + "⌛ **%s** never answered your duel. Your €%d stake is refunded.", + p.DisplayName(ch.ChallengedID), ch.Stake)) + } +} + +func duelIneligibleSelf(reason string) string { + switch reason { + case "dead": + return "You can't duel while dead. Visit `!hospital` first." + case "no adventurer": + return "You have no adventurer. Type `!adventure` to create one." + default: + return fmt.Sprintf("You need to be at least level %d to duel.", duelMinLevel) + } +} + +// ── Flavor ─────────────────────────────────────────────────────────────────── + +var duelWinFlavor = []string{ + "Steel rang, and the crowd knew its champion.", + "A clean finish — you left them nothing to answer with.", + "They fought well. You fought better.", +} + +var duelDecisiveFlavor = []string{ + "It was over before it began. Utterly one-sided.", + "You never gave them a foothold. A rout.", + "The arena barely had time to hush.", +} + +var duelLossFlavor = []string{ + "You'll want that one back. Next time.", + "A hard lesson — but only your pride is bleeding.", + "Close, in places. Not close enough.", +} + +func pickDuelFlavor(pool []string) string { + if len(pool) == 0 { + return "" + } + return pool[rand.IntN(len(pool))] +} diff --git a/internal/plugin/adventure_duel_test.go b/internal/plugin/adventure_duel_test.go new file mode 100644 index 0000000..9c7e229 --- /dev/null +++ b/internal/plugin/adventure_duel_test.go @@ -0,0 +1,274 @@ +package plugin + +import ( + "math" + "math/rand/v2" + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +func newDuelTestDB(t *testing.T) { + t.Helper() + db.Close() + if err := db.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) +} + +// duelSeed builds a deterministic RNG so the two-orientation resolver is +// reproducible across runs — the production path passes nil (global RNG). +func duelSeed(i uint64) *rand.Rand { return rand.New(rand.NewPCG(0x1d4e11, i)) } + +func TestDuelPayout(t *testing.T) { + // Both escrow 2000 → pool 4000 → winner 70% (2800), pot 30% (1200). + w, pot := duelPayout(2000) + if w != 2800 || pot != 1200 { + t.Fatalf("duelPayout(2000) = (%d, %d), want (2800, 1200)", w, pot) + } + // The pot always gets exactly what the winner doesn't; nothing is minted + // or lost against the pooled 2×stake. + for _, stake := range []int{100, 137, 999, 6000} { + w, pot := duelPayout(stake) + if w+pot != stake*2 { + t.Errorf("duelPayout(%d): winner+pot = %d, want pool %d", stake, w+pot, stake*2) + } + if w <= pot { + t.Errorf("duelPayout(%d): winner %d should exceed pot %d", stake, w, pot) + } + } +} + +func TestDuelMaxStake(t *testing.T) { + if got := duelMaxStake(5); got != 2500 { + t.Fatalf("duelMaxStake(5) = %d, want 2500", got) + } +} + +func TestDuelPerHitDamage(t *testing.T) { + // 1d8 (avg 4.5) + ability mod 3 = 7.5 base, + Divine Strike 2 = 9.5, + // × (1 + 0.25 damage bonus) = 11.875. + pc := Combatant{ + Stats: CombatStats{ + Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, + AbilityModForDamage: 3, + }, + Mods: CombatModifiers{DivineStrikePerHit: 2, DamageBonus: 0.25}, + } + got := duelPerHitDamage(pc) + if math.Abs(got-11.875) > 1e-9 { + t.Fatalf("duelPerHitDamage = %v, want 11.875", got) + } + + // Weaponless build falls back to the flat Attack stat. + bare := Combatant{Stats: CombatStats{Attack: 9}} + if got := duelPerHitDamage(bare); got != 9 { + t.Fatalf("weaponless duelPerHitDamage = %v, want 9", got) + } + + // A build that computes to <1 is floored to 1 (a fight always deals damage). + weak := Combatant{Stats: CombatStats{Attack: 0}} + if got := duelPerHitDamage(weak); got != 1 { + t.Fatalf("floored duelPerHitDamage = %v, want 1", got) + } +} + +func TestSynthDuelEnemy(t *testing.T) { + pc := Combatant{ + Name: "Aria", + IsPlayer: true, + Stats: CombatStats{ + MaxHP: 123, + AC: 17, + AttackBonus: 8, + Defense: 5, + Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, + AbilityModForDamage: 3, + }, + Mods: CombatModifiers{ExtraAttacks: 1}, // two swings/round + } + e := synthDuelEnemy(pc) + + if e.IsPlayer { + t.Error("synthesised enemy must not be flagged IsPlayer") + } + if e.Ability != nil { + t.Error("v1 synth enemy carries no MonsterAbility") + } + // HP / AC / to-hit carry over verbatim: to-hit stays faithful both ways. + if e.Stats.MaxHP != 123 || e.Stats.AC != 17 || e.Stats.AttackBonus != 8 { + t.Errorf("stat carryover wrong: got HP=%d AC=%d AB=%d", e.Stats.MaxHP, e.Stats.AC, e.Stats.AttackBonus) + } + // Damage: per-hit (7.5) × swings (2) folded into one flat Attack = 15. + if e.Stats.Attack != 15 { + t.Errorf("folded Attack = %d, want 15 (7.5 × 2 swings)", e.Stats.Attack) + } +} + +// TestResolveDuel_StrongerWinsRegardlessOfRole pins that a clearly superior +// build wins whether it issues or receives the challenge — the two-orientation +// resolver must not hand the win to the attacker seat. +func TestResolveDuel_StrongerWinsRegardlessOfRole(t *testing.T) { + strong := Combatant{ + Name: "Champion", IsPlayer: true, + Stats: CombatStats{ + MaxHP: 220, AC: 19, AttackBonus: 12, + Weapon: &WeaponProfile{DamageCount: 2, DamageSides: 8}, AbilityModForDamage: 5, WeaponProficient: true, + }, + } + weak := Combatant{ + Name: "Novice", IsPlayer: true, + Stats: CombatStats{ + MaxHP: 45, AC: 11, AttackBonus: 3, + Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 4}, AbilityModForDamage: 0, WeaponProficient: true, + }, + } + + for i := uint64(0); i < 50; i++ { + if r := resolveDuel(strong, weak, duelSeed(i)); r.draw || !r.challengerWins { + t.Fatalf("seed %d: strong-as-challenger should win, got %+v", i, r) + } + if r := resolveDuel(weak, strong, duelSeed(i)); r.draw || r.challengerWins { + t.Fatalf("seed %d: strong-as-challenged should win, got %+v", i, r) + } + } +} + +// TestResolveDuel_IdenticalFightersAreUnbiased is the fairness guarantee that +// justifies running both orientations: with two identical builds neither the +// challenger nor the challenged seat may have a systematic edge. A single- +// orientation model (attacker uses full kit, defender is a stat block) would +// skew hard toward the challenger; the both-orientations margin cancels it. +func TestResolveDuel_IdenticalFightersAreUnbiased(t *testing.T) { + fighter := Combatant{ + Name: "Mirror", IsPlayer: true, + Stats: CombatStats{ + MaxHP: 110, AC: 15, AttackBonus: 7, + Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 3, WeaponProficient: true, + }, + } + + const n = 1200 + challengerWins, draws := 0, 0 + for i := uint64(0); i < n; i++ { + r := resolveDuel(fighter, fighter, duelSeed(i)) + switch { + case r.draw: + draws++ + case r.challengerWins: + challengerWins++ + } + } + decided := n - draws + if decided == 0 { + t.Fatal("all identical-fighter duels drew — resolver isn't discriminating") + } + // Unbiased by construction (challenger wins iff D1 > D2 for iid D); allow a + // generous ±10% band so this stays a bias check, not a flaky coin-count. + rate := float64(challengerWins) / float64(decided) + if rate < 0.40 || rate > 0.60 { + t.Fatalf("challenger win rate %.3f over %d decided duels — systematic bias (want ~0.5)", rate, decided) + } +} + +// TestClaimDuelChallenge pins the serialization primitive that guards every +// euro path: exactly one caller may claim a challenge, so accept/decline/expire +// can never both move money for the same row. +func TestClaimDuelChallenge(t *testing.T) { + newDuelTestDB(t) + ch := &advDuelChallenge{ + ChallengeID: "duel-1", + ChallengerID: id.UserID("@a:x"), + ChallengedID: id.UserID("@b:x"), + Stake: 1000, + ExpiresAt: time.Now().UTC().Add(time.Hour), + } + insertDuelChallenge(ch) + + if pendingDuelForChallenged(id.UserID("@b:x")) == nil { + t.Fatal("challenge should be pending for the challenged player before any claim") + } + if !duelInvolves(id.UserID("@a:x")) || !duelInvolves(id.UserID("@b:x")) { + t.Fatal("duelInvolves should see both parties while the challenge is live") + } + + if !claimDuelChallenge("duel-1") { + t.Fatal("first claim must succeed") + } + if claimDuelChallenge("duel-1") { + t.Fatal("second claim must fail — the row is gone, so no double refund/payout") + } + if pendingDuelForChallenged(id.UserID("@b:x")) != nil { + t.Fatal("claimed challenge must no longer be pending") + } +} + +func TestLoadExpiredDuelChallenges(t *testing.T) { + newDuelTestDB(t) + insertDuelChallenge(&advDuelChallenge{ + ChallengeID: "live", ChallengerID: "@a:x", ChallengedID: "@b:x", + Stake: 500, ExpiresAt: time.Now().UTC().Add(time.Hour), + }) + insertDuelChallenge(&advDuelChallenge{ + ChallengeID: "stale", ChallengerID: "@c:x", ChallengedID: "@d:x", + Stake: 500, ExpiresAt: time.Now().UTC().Add(-time.Hour), + }) + got := loadExpiredDuelChallenges() + if len(got) != 1 || got[0].ChallengeID != "stale" { + t.Fatalf("loadExpiredDuelChallenges = %+v, want only the stale one", got) + } +} + +// TestSynthDuelEnemy_PortsDefenseAndProcs pins the fidelity carry-overs added +// after review: a tank's DamageReduct rides onto the synth enemy, and companion +// procs fold their expected damage into its round Attack. +func TestSynthDuelEnemy_PortsDefenseAndProcs(t *testing.T) { + pc := Combatant{ + Stats: CombatStats{ + MaxHP: 100, AC: 15, AttackBonus: 6, + Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 2, // 6.5/hit + }, + Mods: CombatModifiers{ + DamageReduct: 0.6, // Barbarian-style mitigation + SpiritWeaponProc: 0.5, SpiritWeaponDmg: 4, // expected 0.5×(4+3)=3.5/round + }, + } + e := synthDuelEnemy(pc) + if e.Mods.DamageReduct != 0.6 { + t.Errorf("DamageReduct not ported: got %v, want 0.6", e.Mods.DamageReduct) + } + // One swing (6.5) + proc expectation (3.5) = 10 → rounded 10. + if e.Stats.Attack != 10 { + t.Errorf("folded Attack = %d, want 10 (6.5 weapon + 3.5 proc)", e.Stats.Attack) + } +} + +func TestParseDuelStake(t *testing.T) { + p := &AdventurePlugin{} + const level = 5 // cap = 2500 + + // No stake → default to the cap. + if got, err := p.parseDuelStake([]string{"@u"}, level); err != nil || got != 2500 { + t.Fatalf("default stake = (%d, %v), want (2500, nil)", got, err) + } + // Explicit in-band stake, with and without the € prefix. + if got, err := p.parseDuelStake([]string{"@u", "€1500"}, level); err != nil || got != 1500 { + t.Fatalf("€1500 stake = (%d, %v), want (1500, nil)", got, err) + } + // Over the cap is rejected. + if _, err := p.parseDuelStake([]string{"@u", "9999"}, level); err == nil { + t.Error("stake above cap should error") + } + // Below the floor is rejected. + if _, err := p.parseDuelStake([]string{"@u", "10"}, level); err == nil { + t.Error("stake below the minimum should error") + } + // Garbage is rejected. + if _, err := p.parseDuelStake([]string{"@u", "lots"}, level); err == nil { + t.Error("non-numeric stake should error") + } +} diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index 2f6199e..e95d0e4 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -124,6 +124,10 @@ func (p *AdventurePlugin) eventTicker() { // World boss (N6/C3): auto-spawn the monthly Siege on the 1st and // resolve one whose 72h window has lapsed. Own dedup inside. p.worldBossTick() + + // Duels (N6/C2): refund and clear any challenge whose 24h accept + // window lapsed. + p.expireDuelChallenges() } } }