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) } } // ── W2: the bout ───────────────────────────────────────────────────────────── func fightableChar(t *testing.T, uid id.UserID) { t.Helper() if err := createAdvCharacter(uid, "boxer"); err != nil { t.Fatalf("createAdvCharacter: %v", err) } c := &DnDCharacter{ UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 12, STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10, HPMax: 120, HPCurrent: 120, ArmorClass: 18, } if err := SaveDnDCharacter(c); err != nil { t.Fatalf("SaveDnDCharacter: %v", err) } } func TestWorldBossFloorHP(t *testing.T) { newWorldBossTestDB(t) uid := id.UserID("@floor:test.invalid") fightableChar(t, uid) if worldBossFloorHP(uid) { t.Error("floor should be a no-op above 0 HP") } if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil { t.Fatal(err) } if !worldBossFloorHP(uid) { t.Error("floor should raise a 0-HP fighter") } if cur, _ := dndHPSnapshot(uid); cur != 1 { t.Errorf("hp after floor = %d, want 1", cur) } } func TestResolveWorldBossBout_SubtractsAndRecords(t *testing.T) { newWorldBossTestDB(t) uid := id.UserID("@bout:test.invalid") fightableChar(t, uid) now := time.Now().UTC() // Big pool so one bout can only chip it — assert the partial subtract + contrib. bossID, err := insertWorldBoss("Kravok", 3, 100000, now, now.Add(worldBossWindow)) if err != nil { t.Fatal(err) } boss, _ := loadWorldBoss(bossID) p := &AdventurePlugin{} bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05") if err != nil { t.Fatalf("bout: %v", err) } if bout.Damage < 0 { t.Errorf("damage negative: %d", bout.Damage) } if bout.Killed { t.Error("a 100k pool should survive one bout") } if bout.Remaining != 100000-bout.Damage { t.Errorf("remaining %d != max-dmg %d", bout.Remaining, 100000-bout.Damage) } c, _ := loadWorldBossContrib(bossID, uid) if c == nil || c.Fights != 1 || c.Damage != bout.Damage || c.LastFightDate != "2026-07-05" { t.Errorf("contrib mismatch: %+v (bout dmg %d)", c, bout.Damage) } if !worldBossBoutUsedToday(bossID, uid, "2026-07-05") { t.Error("bout should read as used for that day") } if worldBossBoutUsedToday(bossID, uid, "2026-07-06") { t.Error("a different day should still be available") } } func TestResolveWorldBossBout_FellsTinyPool(t *testing.T) { newWorldBossTestDB(t) uid := id.UserID("@kill:test.invalid") fightableChar(t, uid) now := time.Now().UTC() bossID, err := insertWorldBoss("Wisp", 3, 1, now, now.Add(worldBossWindow)) if err != nil { t.Fatal(err) } boss, _ := loadWorldBoss(bossID) p := &AdventurePlugin{} bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05") if err != nil { t.Fatalf("bout: %v", err) } // A L12 fighter lands at least one blow on a T3 dummy over a full boss fight, // felling a 1-HP pool. if !bout.Killed || bout.Remaining != 0 { t.Errorf("tiny pool not felled: killed=%v remaining=%d dmg=%d", bout.Killed, bout.Remaining, bout.Damage) } // Resolution is the caller's job — the bout leaves the boss active. after, _ := loadWorldBoss(bossID) if after.Status != "active" { t.Errorf("bout should not self-resolve; status=%q", after.Status) } } // TestWorldBossTick_ResolvesZeroHPBossAsDefeated: a killing blow that committed // the pool to 0 but (crash/redeploy) never resolved the status must be resolved // as DEFEATED by the ticker net — never fall through to the survive/pot-debit // path. No contributors here, so resolution never touches euro. func TestWorldBossTick_ResolvesZeroHPBossAsDefeated(t *testing.T) { newWorldBossTestDB(t) now := time.Now().UTC() communityPotAdd(1000) bossID, err := insertWorldBoss("Ghost", 3, 100, now, now.Add(worldBossWindow)) if err != nil { t.Fatal(err) } if _, err := db.Get().Exec(`UPDATE world_boss SET hp_current = 0 WHERE id = ?`, bossID); err != nil { t.Fatal(err) } p := &AdventurePlugin{} p.worldBossTick() after, _ := loadWorldBoss(bossID) if after.Status != "defeated" { t.Errorf("status = %q, want defeated (ticker safety net)", after.Status) } if bal := communityPotBalance(); bal != 1000 { t.Errorf("pot = %d, want 1000 — a defeat mints, it must not debit the pot", bal) } } // TestWorldBossTick_ResolvesLapsedBossAsSurvived: a boss whose window lapsed with // the pool still up survives and loots the pot. func TestWorldBossTick_ResolvesLapsedBossAsSurvived(t *testing.T) { newWorldBossTestDB(t) now := time.Now().UTC() communityPotAdd(1000) bossID, err := insertWorldBoss("Titan", 4, 500, now.Add(-worldBossWindow), now.Add(-time.Minute)) if err != nil { t.Fatal(err) } p := &AdventurePlugin{} p.worldBossTick() after, _ := loadWorldBoss(bossID) if after.Status != "survived" { t.Errorf("status = %q, want survived", after.Status) } if bal := communityPotBalance(); bal != 800 { t.Errorf("pot = %d, want 800 after 20%% tribute", bal) } }