Files
gogobee/internal/plugin/adventure_duel.go
prosolis 401b17c3bb N6/C2: player-initiated duels — staked, no-death PvP
`!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
2026-07-10 19:18:45 -07:00

702 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))]
}