Files
gogobee/internal/plugin/adventure_worldboss_test.go
prosolis c3e122694b 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
2026-07-10 17:50:56 -07:00

272 lines
8.1 KiB
Go
Raw Blame History

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