mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Mischief Makers M1 — the core engine, Matrix-only. `!mischief send elite @user`
debits the buyer, tells the games room a hit is out, and an hour later a monster
from the target's own level bracket walks into whatever dungeon they're in.
Survive it and they keep a cut of the money and the buyer is named; don't, and
they wake up on a cart home.
The monster comes from the target's bracket zone pool, not the arena ladder and
not the dungeon they happen to be standing in — the same selection code the M0
pricing sweep ran through, so the fee table can't drift away from the fight it
priced.
Three things that are load-bearing and don't look it:
* Survival is read off the target's HP, not PlayerWon. The engine's timeout is
a retreat, not a lethal blow — somebody who ran out the clock with HP left
held the thing off, and a bought monster that merely outlasted them hasn't
earned a maiming.
* Nobody dies for money, and that includes the party. The delivery skips
closeOutZoneWin/Loss (the fight is extrinsic to the dungeon — crediting it
would let a buyer unlock the target's kill-gated resources for them), so
nothing else floors a downed seat. Without floorMischiefRoster on BOTH
outcomes, a member the leader outlived is left alive at 0 HP, which every
`HPCurrent <= 0` gate reads as broken rather than dead.
* One live contract per target is a partial UNIQUE INDEX, not a read-then-write
check. Placement holds only the *buyer's* lock, so two buyers racing at the
same victim would both pass an in-code test. The loser is refunded.
Payouts are a percentage of the base fee, never of what the buyer actually paid —
the sign surcharge is a pure sink. Capped at 75%, so a survival purse is always
strictly less than the outlay and collusion loses to !baltransfer, which is free.
That cap is the entire anti-collusion story; no danger multiplier needed.
A crash between claiming a contract and closing it out used to be unrecoverable
in the design: the row would strand, the target could never be targeted again,
and the buyer's money was gone. The stale sweep refunds those in full — that one
is our fault, not a bet they lost.
Contract timestamps bind as Go time.Time, never CURRENT_TIMESTAMP. The driver
stores RFC3339 and SQLite's own stamp is space-separated; the two compare
lexicographically wrong.
Pete learns four mischief_* event types in a separate commit, and has to deploy
BEFORE this does — an unknown event_type is a 400, which retries and then parks
the bulletin forever.
786 lines
28 KiB
Go
786 lines
28 KiB
Go
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 := floorHPAtOne(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
|
||
}
|
||
|
||
// floorHPAtOne raises a player to 1 HP if the fight left them at zero, and
|
||
// reports whether it had to. Keeps "no death" honest without undoing the real
|
||
// HP cost of a bout the player mostly survived. Shared by the two no-death
|
||
// fights: the world-boss bout and a Mischief Makers delivery.
|
||
func floorHPAtOne(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())
|
||
}
|