mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N6/C3 W1: world boss — model, scaling, spawn + lifecycle
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
555
internal/plugin/adventure_worldboss.go
Normal file
555
internal/plugin/adventure_worldboss.go
Normal file
@@ -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)
|
||||
}
|
||||
271
internal/plugin/adventure_worldboss_test.go
Normal file
271
internal/plugin/adventure_worldboss_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user