mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Wires the player-facing half of the Siege on top of W1's model + lifecycle. - !adventure worldboss [status|fight|spawn] (alias !adventure siege). Status shows the shared-pool board + your daily bout state + the muster; fight takes today's bout; spawn is an admin override (the "both" spawn decision). - The bout is an arena-style solo fight through runZoneCombat vs a disposable per-tier stat block; the damage dealt (EnemyEntryHP-EnemyEndHP) is subtracted from the shared pool atomically (MAX(0, …) WHERE status='active') win or lose. Real HP cost like the arena, but no death/no hospital: worldBossFloorHP raises a 0-HP loser to 1 so a loss reads as "battered", not a corpse. - One bout per player per UTC day (worldBossBoutUsedToday off the contrib's last_fight_date). The per-user advUserLock serialises a player's own repeat submits so the gate can't be raced; cross-player pool-crossing is safe because only the setWorldBossStatus winner resolves a defeat. - A killing bout trips resolveWorldBossDefeated (minted bounty by fights + cache + treasure roll), after the fighter's own bout DM has streamed. Bout core (resolveWorldBossBout) + the once/day predicate + the HP floor are factored out of the DM path so they're unit-tested end to end with a real fightable character; the DM/games-room emission stays thin (no client stub). Combat golden byte-identical; full plugin suite green. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
371 lines
11 KiB
Go
371 lines
11 KiB
Go
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)
|
||
}
|
||
}
|