From c3e122694bac9956be668e13afdbf381ac134392 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:50:56 -0700 Subject: [PATCH] =?UTF-8?q?N6/C3=20W1:=20world=20boss=20=E2=80=94=20model,?= =?UTF-8?q?=20scaling,=20spawn=20+=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monthly communal "Siege": a named boss camps outside town for 72h with a single shared HP pool. This lands the model and the automatic lifecycle; the player-facing bout command is W2. - New tables world_boss + world_boss_contrib (own tables, outside the saveAdvCharacter fan-out, so a char save can't clobber the shared pool — the isolation adventure_shadow earns). Absent active row == no event; no bootstrap. - Boss sized to the town it will fight: tier from the MEDIAN combined level of any-chat-active players (feedback_presence_is_any_chat, off daily_activity), pool HP = arena per-bout HP × ~2 bouts/active player, clamped [4,60] bouts. Floored at T3. - Lifecycle rides the 1-min eventTicker (no net-new goroutine): auto-spawn on the 1st of each UTC month (JobCompleted dedup) + resolve a lapsed window as a survival. Operator override + the daily bout are W2. - Resolution: defeat mints a bounty scaled by fights fought (not damage — accessibility) + a consumable cache + one low-rate treasure roll each; survival debits 20% of the community pot as a tribute (a pot sink). Both close-outs are guarded on status='active' so they fire once. - Announcements post to the games room (no-op when GAMES_ROOM unset). Pure logic (median, tier bucket, HP scaling, pool subtract/clamp, payout split) is unit-tested; euro/DM emission left thin per the repo's no-client -stub convention. Combat golden byte-identical (never touches SimulateCombat); full plugin suite green. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa --- internal/db/db.go | 26 + internal/plugin/adventure_events.go | 4 + internal/plugin/adventure_worldboss.go | 555 ++++++++++++++++++++ internal/plugin/adventure_worldboss_test.go | 271 ++++++++++ 4 files changed, 856 insertions(+) create mode 100644 internal/plugin/adventure_worldboss.go create mode 100644 internal/plugin/adventure_worldboss_test.go diff --git a/internal/db/db.go b/internal/db/db.go index f06365e..eb3ee28 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -2256,6 +2256,32 @@ CREATE TABLE IF NOT EXISTS adventure_shadow ( last_advanced TEXT NOT NULL DEFAULT '', born_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +-- World boss (N6/C3) — the monthly communal "Siege". One event at a time is +-- live (status='active'); the ticker resolves it at hp_current<=0 (defeated) or +-- after ends_at (survived). History rows are retained (autoincrement id), so +-- world_boss_contrib keys on boss_id. Deliberately its own tables, outside the +-- player_meta save fan-out, so a character save can't clobber the shared pool. +CREATE TABLE IF NOT EXISTS world_boss ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + tier INTEGER NOT NULL DEFAULT 5, + hp_max INTEGER NOT NULL, + hp_current INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + starts_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ends_at DATETIME NOT NULL, + resolved_at DATETIME +); + +CREATE TABLE IF NOT EXISTS world_boss_contrib ( + boss_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + fights INTEGER NOT NULL DEFAULT 0, + damage INTEGER NOT NULL DEFAULT 0, + last_fight_date TEXT NOT NULL DEFAULT '', + PRIMARY KEY (boss_id, user_id) +); ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index 26bf275..2f6199e 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -120,6 +120,10 @@ func (p *AdventurePlugin) eventTicker() { // Reclaim invites nobody answered (N3/P6b). Every read already // filters on the TTL; this just stops the rows accumulating. purgeExpiredInvites() + + // World boss (N6/C3): auto-spawn the monthly Siege on the 1st and + // resolve one whose 72h window has lapsed. Own dedup inside. + p.worldBossTick() } } } diff --git a/internal/plugin/adventure_worldboss.go b/internal/plugin/adventure_worldboss.go new file mode 100644 index 0000000..98985d9 --- /dev/null +++ b/internal/plugin/adventure_worldboss.go @@ -0,0 +1,555 @@ +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 this hit felled it (crossed to zero on this call). A concurrent hit +// that also crosses zero will see killed=false — only the writer that moved the +// pool to exactly zero, or found it already there, owns resolution; the caller +// re-reads to decide. +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 { + 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) +} diff --git a/internal/plugin/adventure_worldboss_test.go b/internal/plugin/adventure_worldboss_test.go new file mode 100644 index 0000000..3bed905 --- /dev/null +++ b/internal/plugin/adventure_worldboss_test.go @@ -0,0 +1,271 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +func newWorldBossTestDB(t *testing.T) { + t.Helper() + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) +} + +func TestWorldBossName_DeterministicAndInPool(t *testing.T) { + first := worldBossNameFor("2026-07") + if again := worldBossNameFor("2026-07"); again != first { + t.Errorf("name not deterministic: %q vs %q", first, again) + } + inPool := false + for _, n := range worldBossNames { + if n == first { + inPool = true + break + } + } + if !inPool { + t.Errorf("generated name %q not in worldBossNames pool", first) + } +} + +func TestMedianInt(t *testing.T) { + cases := []struct { + in []int + want int + }{ + {nil, 0}, + {[]int{5}, 5}, + {[]int{3, 1, 2}, 2}, // odd, unsorted + {[]int{4, 2, 8, 6}, 5}, // even → mean of the two middles (4,6) + {[]int{10, 10, 10, 10}, 10}, // even, all equal + } + for _, c := range cases { + if got := medianInt(c.in); got != c.want { + t.Errorf("medianInt(%v) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestTierForCombinedLevel(t *testing.T) { + cases := []struct{ level, want int }{ + {0, worldBossMinTier}, + {11, worldBossMinTier}, + {12, 4}, + {20, 4}, + {21, 5}, + {99, 5}, + } + for _, c := range cases { + if got := tierForCombinedLevel(c.level); got != c.want { + t.Errorf("tierForCombinedLevel(%d) = %d, want %d", c.level, got, c.want) + } + } +} + +func TestWorldBoss_InsertLoadRoundTrip(t *testing.T) { + newWorldBossTestDB(t) + now := time.Now().UTC().Truncate(time.Second) + id0, err := insertWorldBoss("Gorloth", 5, 2400, now, now.Add(worldBossWindow)) + if err != nil { + t.Fatal(err) + } + active, err := loadActiveWorldBoss() + if err != nil || active == nil { + t.Fatalf("loadActiveWorldBoss: %v (nil=%v)", err, active == nil) + } + if active.ID != id0 || active.Name != "Gorloth" || active.Tier != 5 || + active.HPMax != 2400 || active.HPCurrent != 2400 || active.Status != "active" { + t.Errorf("round-trip mismatch: %+v", active) + } +} + +func TestApplyWorldBossDamage_ClampsAndFells(t *testing.T) { + newWorldBossTestDB(t) + now := time.Now().UTC() + bossID, err := insertWorldBoss("Kravok", 3, 100, now, now.Add(worldBossWindow)) + if err != nil { + t.Fatal(err) + } + + rem, killed, err := applyWorldBossDamage(bossID, 40) + if err != nil || rem != 60 || killed { + t.Fatalf("first hit: rem=%d killed=%v err=%v (want 60,false)", rem, killed, err) + } + + // Overkill clamps at 0 and reports the fell. + rem, killed, err = applyWorldBossDamage(bossID, 999) + if err != nil || rem != 0 || !killed { + t.Fatalf("overkill: rem=%d killed=%v err=%v (want 0,true)", rem, killed, err) + } + + // Negative damage is treated as zero. + if rem, _, err := applyWorldBossDamage(bossID, -5); err != nil || rem != 0 { + t.Fatalf("negative: rem=%d err=%v", rem, err) + } +} + +func TestSetWorldBossStatus_ResolvesOnce(t *testing.T) { + newWorldBossTestDB(t) + now := time.Now().UTC() + bossID, err := insertWorldBoss("Ymirok", 4, 500, now, now.Add(worldBossWindow)) + if err != nil { + t.Fatal(err) + } + if ok, err := setWorldBossStatus(bossID, "defeated"); err != nil || !ok { + t.Fatalf("first close-out: ok=%v err=%v (want true)", ok, err) + } + // A second close-out is a no-op — the boss is no longer active. + if ok, err := setWorldBossStatus(bossID, "survived"); err != nil || ok { + t.Fatalf("double close-out: ok=%v err=%v (want false)", ok, err) + } + // And damage no longer lands on a resolved boss. + if rem, killed, _ := applyWorldBossDamage(bossID, 500); rem != 500 || killed { + t.Errorf("damage on resolved boss changed pool: rem=%d killed=%v", rem, killed) + } +} + +func TestWorldBossContrib_UpsertAccumulates(t *testing.T) { + newWorldBossTestDB(t) + user := id.UserID("@a:test.invalid") + if err := upsertWorldBossContrib(1, user, 30, "2026-07-01"); err != nil { + t.Fatal(err) + } + if err := upsertWorldBossContrib(1, user, 45, "2026-07-02"); err != nil { + t.Fatal(err) + } + c, err := loadWorldBossContrib(1, user) + if err != nil || c == nil { + t.Fatalf("load: %v (nil=%v)", err, c == nil) + } + if c.Fights != 2 || c.Damage != 75 || c.LastFightDate != "2026-07-02" { + t.Errorf("accumulate mismatch: %+v", c) + } +} + +func TestLoadWorldBossContribs_OrderedByFights(t *testing.T) { + newWorldBossTestDB(t) + a := id.UserID("@a:test.invalid") + b := id.UserID("@b:test.invalid") + // a: 1 fight; b: 2 fights → b sorts first. + upsertWorldBossContrib(7, a, 100, "2026-07-01") + upsertWorldBossContrib(7, b, 10, "2026-07-01") + upsertWorldBossContrib(7, b, 10, "2026-07-02") + got, err := loadWorldBossContribs(7) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].UserID != b || got[1].UserID != a { + t.Errorf("order = %+v, want b then a", got) + } +} + +func TestComputeWorldBossPayouts_ScalesByFights(t *testing.T) { + contribs := []worldBossContrib{ + {UserID: "@a:t", Fights: 3, Damage: 999}, + {UserID: "@b:t", Fights: 1, Damage: 10}, + {UserID: "@c:t", Fights: 0, Damage: 0}, // no fights → no payout + } + pays := computeWorldBossPayouts(contribs, 1000) + if len(pays) != 2 { + t.Fatalf("got %d payouts, want 2 (zero-fight excluded)", len(pays)) + } + if pays[0].Euro != 3000 || pays[1].Euro != 1000 { + t.Errorf("payouts = %d,%d want 3000,1000", pays[0].Euro, pays[1].Euro) + } +} + +// TestWorldBossSpawnPlan_SizesToActiveTown seeds three active max-level players +// and checks the boss is T5 with a pool scaled to the turnout. +func TestWorldBossSpawnPlan_SizesToActiveTown(t *testing.T) { + newWorldBossTestDB(t) + today := time.Now().UTC().Format("2006-01-02") + for _, u := range []string{"@a:test.invalid", "@b:test.invalid", "@c:test.invalid"} { + c := &AdventureCharacter{UserID: id.UserID(u), DisplayName: "P", Alive: true, + ForagingSkill: 30, CreatedAt: time.Now().UTC()} + if err := saveAdvCharacter(c); err != nil { + t.Fatal(err) + } + if _, err := db.Get().Exec( + `INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)`, + u, today); err != nil { + t.Fatal(err) + } + } + tier, hpMax, activeN := worldBossSpawnPlan() + if activeN != 3 { + t.Errorf("activeN = %d, want 3", activeN) + } + if tier != 5 { + t.Errorf("tier = %d, want 5 (combined >= 21)", tier) + } + // bouts = 3 × 2.0 = 6; perBout at T5 = 400 → pool 2400. + perBout, _, _, _, _ := arenaTierBaseStats(5) + if want := perBout * 6; hpMax != want { + t.Errorf("hpMax = %d, want %d", hpMax, want) + } +} + +// TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss: no active players still yields a +// beatable floor boss so a manual spawn works. +func TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss(t *testing.T) { + newWorldBossTestDB(t) + tier, hpMax, activeN := worldBossSpawnPlan() + if activeN != 0 { + t.Errorf("activeN = %d, want 0", activeN) + } + if tier != worldBossMinTier { + t.Errorf("tier = %d, want floor %d", tier, worldBossMinTier) + } + perBout, _, _, _, _ := arenaTierBaseStats(worldBossMinTier) + if want := perBout * worldBossMinBouts; hpMax != want { + t.Errorf("hpMax = %d, want %d (min bouts floor)", hpMax, want) + } +} + +func TestSpawnWorldBoss_RefusesWhenOneIsActive(t *testing.T) { + newWorldBossTestDB(t) + p := &AdventurePlugin{} + first, err := p.spawnWorldBoss("2026-07") + if err != nil || first == nil { + t.Fatalf("first spawn: %v (nil=%v)", err, first == nil) + } + second, err := p.spawnWorldBoss("2026-07") + if err == nil { + t.Error("second spawn should refuse while one is active") + } + if second == nil || second.ID != first.ID { + t.Error("refusal should return the existing active boss") + } +} + +// TestResolveWorldBossSurvived_DebitsPot exercises the survive path end to end: +// the boss closes out and the pot loses its tribute. +func TestResolveWorldBossSurvived_DebitsPot(t *testing.T) { + newWorldBossTestDB(t) + communityPotAdd(1000) + now := time.Now().UTC() + bossID, err := insertWorldBoss("Vornath", 5, 2400, now.Add(-worldBossWindow), now.Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + boss, _ := loadWorldBoss(bossID) + p := &AdventurePlugin{} + p.resolveWorldBossSurvived(boss) + + after, _ := loadWorldBoss(bossID) + if after.Status != "survived" { + t.Errorf("status = %q, want survived", after.Status) + } + // tribute = 20% of 1000 = 200 → pot left with 800. + if bal := communityPotBalance(); bal != 800 { + t.Errorf("pot = %d, want 800 after 20%% tribute", bal) + } +}