Files
gogobee/internal/plugin/adventure_worldboss.go
prosolis e2c0c3359b N6/C3 review fixes: close the killed-boss-resolves-as-survived window
Two findings from an adversarial review of the C3 diff, both fixed:

1. (medium) A killing blow commits the shared pool to 0 inline, but the status
   flip to 'defeated' was deferred until AFTER the multi-second narration stream.
   In that window a crash/redeploy or the ticker's survive path could resolve a
   boss the town KILLED as a survival — debiting the pot and paying no bounties.
   Fix: resolve the defeat BEFORE streaming narration, and add a ticker safety
   net that resolves any active 0-HP boss as defeated (never falls through to the
   survive/pot-debit branch). Both guarded by setWorldBossStatus, so still once.

2. (minor) Pool damage was applied before the best-effort contrib/gate write, so
   a failed upsert let a player refight a pool they had already drained and lose
   the credit. Fix: write the contribution (which sets the once-per-day gate)
   BEFORE draining the pool, as a hard error that aborts the bout with the pool
   untouched.

Also corrected the applyWorldBossDamage comment (two concurrent killers CAN both
see killed=true; the status guard dedupes the payout — the old comment claimed
only one would). New ticker tests cover both resolution paths; suite green,
golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 18:09:11 -07:00

785 lines
28 KiB
Go
Raw Permalink 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
import (
"database/sql"
"fmt"
"hash/fnv"
"log/slog"
"sort"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// World boss (N6/C3) — the monthly communal "Siege". A named boss camps outside
// town for a fixed window with a single shared HP pool. Every player gets one
// bout per day against it (an arena-style fight through runZoneCombat); the
// damage they deal is subtracted from the shared pool whether the individual
// bout is won or lost. The pool falling to zero defeats the boss; the window
// lapsing with the pool still up means the boss survives and loots the town.
//
// Economy (decisions locked 2026-07-10): a defeat pays every contributor from a
// freshly minted bounty scaled by fights fought (not damage — accessibility),
// plus a consumable cache and one low-rate treasure roll each; a survival debits
// the community pot as a tribute (a sink the economy wants). The bout itself
// costs real HP the way the arena does — no restore — but never permadeaths.
//
// The event lives in its own tables (world_boss / world_boss_contrib), outside
// the saveAdvCharacter fan-out, so a character save can never clobber the shared
// pool — the same structural isolation adventure_shadow earns.
const (
// worldBossWindow is how long the boss camps outside town once it spawns.
worldBossWindow = 72 * time.Hour
// worldBossPresenceDays is the any-chat presence lookback used to size the
// boss to the community that will actually fight it
// (feedback_presence_is_any_chat).
worldBossPresenceDays = 14
// worldBossMinTier floors the boss tier — the Siege is always a serious
// fight, never a T1/T2 pushover, even for a low-level town.
worldBossMinTier = 3
// worldBossBoutsPerPlayer sizes the shared pool: expected full-damage bouts
// to fell the boss ≈ activePlayers × this. Higher = a longer siege that needs
// more of the town to turn up. Tunable; watch prod turnout.
worldBossBoutsPerPlayer = 2.0
// worldBossMinBouts / worldBossMaxBouts clamp the pool so a near-empty town
// still gets a beatable boss and a huge town doesn't get an unkillable one.
worldBossMinBouts = 4
worldBossMaxBouts = 60
// worldBossDefeatBaseEuro is the minted bounty a single fight earns a
// contributor on a defeat; total payout = base × fights fought.
worldBossDefeatBaseEuro = 1500
// worldBossTributePct is the fraction of the community pot the boss loots on
// a survival — a visible pot sink.
worldBossTributePct = 0.20
// worldBossTreasureWeight is the (low) weight of the single treasure roll each
// contributor gets on a defeat.
worldBossTreasureWeight = advTreasureWeightElite
// worldBossCacheSize is how many tier consumables each contributor is handed
// on a defeat.
worldBossCacheSize = 2
)
// worldBossState mirrors an active world_boss row.
type worldBossState struct {
ID int64
Name string
Tier int
HPMax int
HPCurrent int
Status string // active | defeated | survived
StartsAt time.Time
EndsAt time.Time
}
// worldBossContrib mirrors a world_boss_contrib row — one player's tally against
// one boss.
type worldBossContrib struct {
BossID int64
UserID id.UserID
Fights int
Damage int
LastFightDate string
}
// worldBossNames is the pool a Siege boss is named from — big, ancient,
// town-threatening things. Picked deterministically per event so a given month's
// boss reads the same to everyone.
var worldBossNames = []string{
"Gorloth the Sunderer", "The Ashen Wyrm", "Kravok, Maw of the Deep",
"Nyxaris the Devouring", "The Iron Colossus", "Ssath'ra, Coil of Ruin",
"Ymirok the Frostbound", "The Hollow Leviathan", "Dravmaug, Ender of Walls",
"The Bone Cathedral", "Vornath the Insatiable", "The Screaming Tide",
}
// worldBossNameFor picks a stable boss name from an event key (the spawn month),
// so the same Siege always reads by the same name.
func worldBossNameFor(eventKey string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(eventKey))
return worldBossNames[int(h.Sum32())%len(worldBossNames)]
}
// ── Persistence ──────────────────────────────────────────────────────────────
func scanWorldBoss(row interface{ Scan(...any) error }) (*worldBossState, error) {
b := &worldBossState{}
err := row.Scan(&b.ID, &b.Name, &b.Tier, &b.HPMax, &b.HPCurrent, &b.Status, &b.StartsAt, &b.EndsAt)
if err != nil {
return nil, err
}
return b, nil
}
// loadActiveWorldBoss returns the live Siege, or (nil, nil) if none is camped.
func loadActiveWorldBoss() (*worldBossState, error) {
b, err := scanWorldBoss(db.Get().QueryRow(
`SELECT id, name, tier, hp_max, hp_current, status, starts_at, ends_at
FROM world_boss WHERE status = 'active' ORDER BY id DESC LIMIT 1`))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return b, nil
}
// loadWorldBoss reads one boss by id (history included). (nil, nil) if absent.
func loadWorldBoss(bossID int64) (*worldBossState, error) {
b, err := scanWorldBoss(db.Get().QueryRow(
`SELECT id, name, tier, hp_max, hp_current, status, starts_at, ends_at
FROM world_boss WHERE id = ?`, bossID))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return b, nil
}
// insertWorldBoss writes a fresh active boss and returns its id.
func insertWorldBoss(name string, tier, hpMax int, startsAt, endsAt time.Time) (int64, error) {
res, err := db.Get().Exec(
`INSERT INTO world_boss (name, tier, hp_max, hp_current, status, starts_at, ends_at)
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
name, tier, hpMax, hpMax, startsAt, endsAt)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// applyWorldBossDamage subtracts damage from the shared pool atomically, clamped
// at zero, only while the boss is still active. Returns the pool's new value and
// whether the pool is now down (killed). The UPDATE and the re-read are separate
// statements, so two concurrent killers can BOTH observe killed=true — that is
// fine: resolveWorldBossDefeated dedupes on the status='active' guard in
// setWorldBossStatus, so only one payout ever fires. killed is just "should the
// caller attempt resolution".
func applyWorldBossDamage(bossID int64, dmg int) (remaining int, killed bool, err error) {
if dmg < 0 {
dmg = 0
}
_, err = db.Get().Exec(
`UPDATE world_boss SET hp_current = MAX(0, hp_current - ?)
WHERE id = ? AND status = 'active'`, dmg, bossID)
if err != nil {
return 0, false, err
}
b, err := loadWorldBoss(bossID)
if err != nil || b == nil {
return 0, false, err
}
return b.HPCurrent, b.HPCurrent <= 0 && b.Status == "active", nil
}
// setWorldBossStatus closes out a boss (defeated | survived) and stamps
// resolved_at. Guarded on status='active' so a double resolution is a no-op.
func setWorldBossStatus(bossID int64, status string) (bool, error) {
res, err := db.Get().Exec(
`UPDATE world_boss SET status = ?, resolved_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'active'`, status, bossID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// upsertWorldBossContrib bumps a player's tally against a boss.
func upsertWorldBossContrib(bossID int64, userID id.UserID, dmg int, dateKey string) error {
_, err := db.Get().Exec(
`INSERT INTO world_boss_contrib (boss_id, user_id, fights, damage, last_fight_date)
VALUES (?, ?, 1, ?, ?)
ON CONFLICT(boss_id, user_id) DO UPDATE SET
fights = fights + 1,
damage = damage + excluded.damage,
last_fight_date = excluded.last_fight_date`,
bossID, string(userID), dmg, dateKey)
return err
}
// loadWorldBossContrib reads one player's tally, or (nil, nil) if they haven't
// fought this boss.
func loadWorldBossContrib(bossID int64, userID id.UserID) (*worldBossContrib, error) {
c := &worldBossContrib{BossID: bossID, UserID: userID}
err := db.Get().QueryRow(
`SELECT fights, damage, last_fight_date FROM world_boss_contrib
WHERE boss_id = ? AND user_id = ?`, bossID, string(userID)).
Scan(&c.Fights, &c.Damage, &c.LastFightDate)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return c, nil
}
// loadWorldBossContribs reads every contributor to a boss, ordered by fights
// desc then damage desc (the board order).
func loadWorldBossContribs(bossID int64) ([]worldBossContrib, error) {
rows, err := db.Get().Query(
`SELECT user_id, fights, damage, last_fight_date FROM world_boss_contrib
WHERE boss_id = ? ORDER BY fights DESC, damage DESC`, bossID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []worldBossContrib
for rows.Next() {
c := worldBossContrib{BossID: bossID}
var uid string
if err := rows.Scan(&uid, &c.Fights, &c.Damage, &c.LastFightDate); err != nil {
return nil, err
}
c.UserID = id.UserID(uid)
out = append(out, c)
}
return out, rows.Err()
}
// ── Scaling ──────────────────────────────────────────────────────────────────
// activePlayerIDs returns the user ids that have chatted (any message, not just
// commands) in the last `days` days — the any-chat presence signal
// (feedback_presence_is_any_chat), read off the streaks plugin's daily_activity
// table.
func activePlayerIDs(days int) ([]id.UserID, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
rows, err := db.Get().Query(
`SELECT DISTINCT user_id FROM daily_activity WHERE date >= ?`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []id.UserID
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
return nil, err
}
out = append(out, id.UserID(uid))
}
return out, rows.Err()
}
// combinedAdvLevel is the same "how strong is this adventurer" reducer TwinBee
// uses — combat level plus the three gathering skills.
func combinedAdvLevel(c *AdventureCharacter) int {
return dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
}
// medianInt returns the median of a sorted-or-not int slice (0 for empty).
func medianInt(xs []int) int {
if len(xs) == 0 {
return 0
}
s := append([]int(nil), xs...)
sort.Ints(s)
mid := len(s) / 2
if len(s)%2 == 1 {
return s[mid]
}
return (s[mid-1] + s[mid]) / 2
}
// tierForCombinedLevel buckets a combined adventure level to a zone tier, floored
// at worldBossMinTier. Same thresholds as twinBeeMaxTier (combined 12 → T4,
// 21 → T5), applied here to the median rather than the max.
func tierForCombinedLevel(level int) int {
switch {
case level >= 21:
return 5
case level >= 12:
return 4
default:
return worldBossMinTier
}
}
// groupInt renders an int with thousands separators (no currency symbol),
// reusing fmtEuro's grouping.
func groupInt(n int) string {
return strings.TrimPrefix(fmtEuro(n), "€")
}
// worldBossSpawnPlan computes the boss's tier and pool HP from the players active
// in the presence window. Returns the tier, HP, and the active-player count that
// drove the sizing (0 players ⇒ a floor boss so a manual spawn still works).
func worldBossSpawnPlan() (tier, hpMax, activeN int) {
ids, err := activePlayerIDs(worldBossPresenceDays)
if err != nil {
slog.Warn("worldboss: active player lookup failed", "err", err)
}
active := make(map[id.UserID]bool, len(ids))
for _, uid := range ids {
active[uid] = true
}
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Warn("worldboss: char load failed", "err", err)
}
var levels []int
for i := range chars {
c := &chars[i]
if !active[c.UserID] || !c.Alive {
continue
}
levels = append(levels, combinedAdvLevel(c))
}
activeN = len(levels)
tier = tierForCombinedLevel(medianInt(levels))
bouts := int(float64(max(activeN, 1)) * worldBossBoutsPerPlayer)
if bouts < worldBossMinBouts {
bouts = worldBossMinBouts
}
if bouts > worldBossMaxBouts {
bouts = worldBossMaxBouts
}
perBout, _, _, _, _ := arenaTierBaseStats(tier)
hpMax = perBout * bouts
return tier, hpMax, activeN
}
// worldBossTemplate builds the disposable per-bout enemy stat block. The shared
// pool lives in world_boss.hp_current; this template is just the thing a single
// player swings at, so its own HP is the arena tier's boss HP — beatable in a
// bout, which lets a strong player land a full boss's worth of damage on the
// pool.
func worldBossTemplate(name string, tier int) DnDMonsterTemplate {
hp, ac, atk, ab, cr := arenaTierBaseStats(tier)
return DnDMonsterTemplate{
ID: fmt.Sprintf("worldboss_t%d", tier),
Name: name,
CR: cr,
HP: hp,
AC: ac,
Attack: atk,
AttackBonus: ab,
Speed: 12,
BlockRate: 0.05,
XPValue: arenaTiers[tier-1].BattleXP * 5,
Notes: "the Siege",
}
}
// ── Lifecycle ────────────────────────────────────────────────────────────────
// spawnWorldBoss mints a fresh Siege and announces it. It refuses if one is
// already camped (only one at a time). eventKey names the boss deterministically
// (the spawn month for the auto path, a stamp for a manual one).
func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, error) {
if existing, err := loadActiveWorldBoss(); err != nil {
return nil, err
} else if existing != nil {
return existing, fmt.Errorf("a world boss is already active")
}
tier, hpMax, activeN := worldBossSpawnPlan()
name := worldBossNameFor(eventKey)
now := time.Now().UTC()
ends := now.Add(worldBossWindow)
bossID, err := insertWorldBoss(name, tier, hpMax, now, ends)
if err != nil {
return nil, err
}
boss, err := loadWorldBoss(bossID)
if err != nil {
return nil, err
}
p.announceWorldBossSpawn(boss, activeN)
slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN)
return boss, nil
}
// worldBossTick rides the 1-minute event ticker. It auto-spawns a boss on the
// first of each UTC month and resolves a live boss whose window has lapsed. The
// defeat path is not here — a bout crossing the pool to zero resolves inline
// (W2), because the ticker never sees the pool between two 60s reads.
func (p *AdventurePlugin) worldBossTick() {
boss, err := loadActiveWorldBoss()
if err != nil {
slog.Warn("worldboss: tick load failed", "err", err)
return
}
now := time.Now().UTC()
if boss != nil {
// Safety net: a killing blow commits the pool to 0 inline, but if the
// process died (or was redeployed) before the bout resolved the status,
// the boss is left active/0-HP. Resolve it as the defeat it was — never
// let the survive branch below debit the pot for a boss the town killed.
if boss.HPCurrent <= 0 {
p.resolveWorldBossDefeated(boss)
return
}
if now.After(boss.EndsAt) {
p.resolveWorldBossSurvived(boss)
}
return
}
// No boss camped — auto-spawn on the 1st of the month, once.
if now.Day() != 1 {
return
}
monthKey := now.Format("2006-01")
if db.JobCompleted("worldboss_spawn", monthKey) {
return
}
if _, err := p.spawnWorldBoss(monthKey); err != nil {
slog.Warn("worldboss: auto-spawn failed", "err", err)
return
}
db.MarkJobCompleted("worldboss_spawn", monthKey)
}
// ── Resolution ───────────────────────────────────────────────────────────────
// worldBossPayout is one contributor's minted defeat reward.
type worldBossPayout struct {
UserID id.UserID
Fights int
Euro int
}
// computeWorldBossPayouts scales the minted bounty by fights fought (not damage —
// accessibility). Pure so the split is testable without a euro/Matrix stub.
func computeWorldBossPayouts(contribs []worldBossContrib, baseEuro int) []worldBossPayout {
out := make([]worldBossPayout, 0, len(contribs))
for _, c := range contribs {
if c.Fights <= 0 {
continue
}
out = append(out, worldBossPayout{UserID: c.UserID, Fights: c.Fights, Euro: baseEuro * c.Fights})
}
return out
}
// resolveWorldBossDefeated pays every contributor a minted bounty scaled by
// fights, plus a consumable cache and one low-rate treasure roll each, then
// announces the kill. Guarded so it fires once.
func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) {
won, err := setWorldBossStatus(boss.ID, "defeated")
if err != nil {
slog.Warn("worldboss: defeat close-out failed", "err", err)
return
}
if !won {
return // someone else already resolved it
}
contribs, err := loadWorldBossContribs(boss.ID)
if err != nil {
slog.Warn("worldboss: contrib load failed", "err", err)
}
payouts := computeWorldBossPayouts(contribs, worldBossDefeatBaseEuro)
treasureZone := worldBossTreasureZone(boss.Tier)
for _, pay := range payouts {
p.euro.Credit(pay.UserID, float64(pay.Euro), "worldboss_bounty")
for _, item := range consumableCache(boss.Tier, worldBossCacheSize) {
it := item
p.grantZoneItem(pay.UserID, &it, "🧪")
}
if treasureZone != "" {
p.rollZoneTreasure(pay.UserID, treasureZone, worldBossTreasureWeight)
}
}
p.announceWorldBossDefeated(boss, payouts)
slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts))
}
// resolveWorldBossSurvived debits the community pot as a tribute (a sink) and
// announces the town's loss. Guarded so it fires once.
func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) {
won, err := setWorldBossStatus(boss.ID, "survived")
if err != nil {
slog.Warn("worldboss: survive close-out failed", "err", err)
return
}
if !won {
return
}
tribute := int(float64(communityPotBalance()) * worldBossTributePct)
paid := 0
if tribute > 0 && communityPotDebit(tribute) {
paid = tribute
}
p.announceWorldBossSurvived(boss, paid)
slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid)
}
// worldBossTreasureZone maps the boss tier to a representative zone whose treasure
// table the defeat roll draws from. "" if no zone of that tier exists.
func worldBossTreasureZone(tier int) ZoneID {
zs := zonesByTier(ZoneTier(tier))
if len(zs) == 0 {
return ""
}
return zs[0].ID
}
// ── Announcements (games room) ───────────────────────────────────────────────
// announceWorldBoss posts to the shared games room. No-op if GAMES_ROOM is unset.
func (p *AdventurePlugin) announceWorldBoss(text string) {
gr := gamesRoom()
if gr == "" {
return
}
if err := p.SendMessage(gr, text); err != nil {
slog.Warn("worldboss: games-room announce failed", "err", err)
}
}
func (p *AdventurePlugin) announceWorldBossSpawn(boss *worldBossState, activeN int) {
p.announceWorldBoss(fmt.Sprintf(
"⚔️ **The Siege has begun!** **%s** (Tier %d) camps outside town with **%s HP**.\n"+
"Everyone gets **one bout per day** for the next 72 hours — `!adventure worldboss fight`. "+
"Every hit chips the shared pool. Fell it together for a bounty; let it stand and it loots the town.",
boss.Name, boss.Tier, groupInt(boss.HPMax)))
}
func (p *AdventurePlugin) announceWorldBossDefeated(boss *worldBossState, payouts []worldBossPayout) {
var top string
if len(payouts) > 0 {
top = fmt.Sprintf(" Top of the muster: %s (%d fights).",
p.DisplayName(payouts[0].UserID), payouts[0].Fights)
}
p.announceWorldBoss(fmt.Sprintf(
"🏆 **%s has fallen!** %d adventurer(s) brought it down.%s\n"+
"Bounties, supply caches, and salvage have been paid out to everyone who fought.",
boss.Name, len(payouts), top))
}
func (p *AdventurePlugin) announceWorldBossSurvived(boss *worldBossState, tribute int) {
msg := fmt.Sprintf("💀 **%s still stands.** The Siege is over — the town could not bring it down.", boss.Name)
if tribute > 0 {
msg += fmt.Sprintf(" It loots **€%s** from the community pot on its way out.", groupInt(tribute))
}
p.announceWorldBoss(msg)
}
// ── Player command (W2) ──────────────────────────────────────────────────────
// handleWorldBossCmd routes `!adventure worldboss [status|fight|spawn]` (alias
// `!adventure siege …`). Bare / "status" shows the board; "fight" takes today's
// bout; "spawn" is an operator override.
func (p *AdventurePlugin) handleWorldBossCmd(ctx MessageContext, args string) error {
switch strings.ToLower(strings.TrimSpace(args)) {
case "", "status":
return p.worldBossStatusDM(ctx)
case "fight":
return p.fightWorldBoss(ctx)
case "spawn":
return p.worldBossOperatorSpawn(ctx)
}
return p.SendDM(ctx.Sender, "Usage: `!adventure worldboss [status|fight]`")
}
// worldBossOperatorSpawn lets an admin start a Siege on demand (the override half
// of the "both" spawn decision). Silent to non-admins, like the other admin
// commands.
func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
key := "manual-" + time.Now().UTC().Format("2006-01-02T15:04")
boss, err := p.spawnWorldBoss(key)
if err != nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("Could not spawn the Siege: %v", err))
}
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Spawned **%s** (Tier %d, %s HP). It camps for 72 hours.",
boss.Name, boss.Tier, groupInt(boss.HPMax)))
}
// fightWorldBoss runs one player's daily bout against the Siege: an arena-style
// solo fight whose damage is subtracted from the shared pool win or lose. Real
// HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but
// standing. The per-user lock serialises a player's own repeat submits, so the
// once-per-day gate can't be raced by a double-tap.
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
boss, err := loadActiveWorldBoss()
if err != nil {
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
}
if boss == nil {
return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.")
}
char, err := loadAdvCharacter(ctx.Sender)
if err != nil || char == nil {
return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.")
}
today := time.Now().UTC().Format("2006-01-02")
if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
}
bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today)
if err != nil {
slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.")
}
// Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is
// already committed to 0; flipping the status now closes the window where a
// crash or the ticker's survive path could resolve a killed boss as a
// survival and debit the pot instead of paying bounties.
if bout.Killed {
p.resolveWorldBossDefeated(boss)
}
playerName, _ := loadDisplayName(ctx.Sender)
if playerName == "" {
playerName = "You"
}
phaseMessages := append([]string{
fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier),
}, RenderCombatLog(bout.Combat, playerName, boss.Name)...)
var footer string
if bout.Killed {
footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name)
} else {
footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.",
bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax))
}
if bout.Battered {
footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing."
}
<-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer)
return nil
}
// worldBossBoutUsedToday reports whether a player has already spent their daily
// bout against this boss.
func worldBossBoutUsedToday(bossID int64, userID id.UserID, dateKey string) bool {
c, err := loadWorldBossContrib(bossID, userID)
return err == nil && c != nil && c.LastFightDate == dateKey
}
// worldBossBoutResult is the outcome of one resolved bout, before narration.
type worldBossBoutResult struct {
Damage int
Remaining int
Killed bool
Battered bool
Combat CombatResult
}
// resolveWorldBossBout runs the arena-style fight, applies the real HP cost with
// the no-death floor, subtracts the damage dealt from the shared pool, and
// records the contribution. It performs no DM or games-room I/O, so it is the
// testable core of fightWorldBoss.
func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBossState, dateKey string) (worldBossBoutResult, error) {
monster := worldBossTemplate(boss.Name, boss.Tier)
result, err := p.runZoneCombat(userID, monster, boss.Tier, bossCombatPhases, 50)
if err != nil {
return worldBossBoutResult{}, err
}
// 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.
battered := worldBossFloorHP(userID)
dmg := result.EnemyEntryHP - result.EnemyEndHP
if dmg < 0 {
dmg = 0
}
// Record the contribution (which also sets the once-per-day gate) BEFORE
// draining the shared pool. If this write fails we must not have already
// drained the pool — otherwise the player could refight a pool they emptied
// and lose the credit for it. A hard error here aborts the bout; the HP cost
// was already paid, but the pool is untouched and the player can retry.
if err := upsertWorldBossContrib(boss.ID, userID, dmg, dateKey); err != nil {
return worldBossBoutResult{}, err
}
remaining, killed, err := applyWorldBossDamage(boss.ID, dmg)
if err != nil {
return worldBossBoutResult{}, err
}
return worldBossBoutResult{
Damage: dmg, Remaining: remaining, Killed: killed, Battered: battered, Combat: result,
}, nil
}
// worldBossFloorHP raises a player to 1 HP if the bout left them at zero, and
// reports whether it had to. Keeps "no death" honest without undoing the real
// HP cost of a bout the player mostly survived.
func worldBossFloorHP(userID id.UserID) bool {
cur, _ := dndHPSnapshot(userID)
if cur > 0 {
return false
}
if _, err := db.Get().Exec(
`UPDATE dnd_character SET hp_current = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
string(userID)); err != nil {
slog.Warn("worldboss: hp floor failed", "user", userID, "err", err)
}
return true
}
// worldBossStatusDM renders the current Siege board to the caller.
func (p *AdventurePlugin) worldBossStatusDM(ctx MessageContext) error {
boss, err := loadActiveWorldBoss()
if err != nil {
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
}
if boss == nil {
return p.SendDM(ctx.Sender,
"No Siege is camped outside town right now. The next one arrives at the start of the month.")
}
var sb strings.Builder
pct := 0
if boss.HPMax > 0 {
pct = boss.HPCurrent * 100 / boss.HPMax
}
fmt.Fprintf(&sb, "⚔️ **The Siege — %s** (Tier %d)\n", boss.Name, boss.Tier)
fmt.Fprintf(&sb, "Pool: **%s / %s HP** (%d%%)\n", groupInt(boss.HPCurrent), groupInt(boss.HPMax), pct)
if left := time.Until(boss.EndsAt); left > 0 {
h := int(left.Hours())
m := int(left.Minutes()) % 60
fmt.Fprintf(&sb, "Time left: **%dh %dm**\n", h, m)
}
today := time.Now().UTC().Format("2006-01-02")
if c, _ := loadWorldBossContrib(boss.ID, ctx.Sender); c != nil {
line := fmt.Sprintf("\nYou: %d fight(s), %s damage dealt.", c.Fights, groupInt(c.Damage))
if c.LastFightDate == today {
line += " Your bout is spent for today."
} else {
line += " Your bout is ready — `!adventure worldboss fight`."
}
sb.WriteString(line + "\n")
} else {
sb.WriteString("\nYou haven't fought yet — `!adventure worldboss fight`.\n")
}
if contribs, _ := loadWorldBossContribs(boss.ID); len(contribs) > 0 {
sb.WriteString("\n**Top of the muster:**\n")
for i, c := range contribs {
if i >= 5 {
break
}
fmt.Fprintf(&sb, "%d. %s — %d fight(s)\n", i+1, p.DisplayName(c.UserID), c.Fights)
}
}
return p.SendDM(ctx.Sender, sb.String())
}