mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
B1 — tempering. `!adventure temper` pushes an owned magic item one rung up the rarity ladder at the blacksmith, capped at Legendary. Rarity lives on the registry definition, so an upgraded instance records its progress as a `temper` step count on its own row (adventure_inventory, magic_item_equipped) and effective rarity is derived on read. The stored base rarity is never written back, so an item cannot double-bump across a load/save round-trip. Effects flow through the existing rarity scalars — no new combat math, and the Legendary cap means this accelerates reaching the current ceiling rather than raising it. Costs mirror the crafting ladder (10k/25k/60k/150k, Foraging 15/20/25/30). Only the Legendary rung consumes a T5 material; gating the lower rungs on one would make tempering unreachable until a player already clears T5. Two plan anchors were wrong: thyraks_core and portal_fragment are not loot-note strings needing promotion — they are UniqueAlways slate entries that already materialize as inventory rows on every T5 clear. B4 — expedition achievement wing: first-clear and all-zones-cleared per tier (10), a quiet-clear for keeping peak threat under 50, and temper_legendary. The quiet-clear requires max_threat_seen to be *present*, not merely low: recordMaxThreat samples only on day rollover and never stores a zero, so an absent key means no threat history, not low threat. The plan's no-death-T4 achievement is not shipped — no per-expedition death counter exists — and pet-saved-my-life already ships as combat_pet_save. C4 — arena seasons. Standings are derived from arena_history.created_at per calendar quarter rather than wiping arena_stats: same visible reset, but lifetime totals survive for `!arena stats` and no destructive job can fire twice. Crowns archive to arena_season_titles rather than player_meta.title, which already carries the Survivalist milestone. Rollover rides the existing midnight ticker and self-dedups on the season key, so a bot that was down on the boundary catches up on its next wake.
188 lines
6.2 KiB
Go
188 lines
6.2 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
func TestArenaSeasonKeyAndBounds(t *testing.T) {
|
|
tests := []struct {
|
|
when time.Time
|
|
key string
|
|
wantStart time.Time
|
|
}{
|
|
{time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
|
|
{time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
|
|
{time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), "2026-Q2", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)},
|
|
{time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC), "2026-Q3", time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)},
|
|
{time.Date(2026, 12, 31, 23, 0, 0, 0, time.UTC), "2026-Q4", time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := arenaSeasonKey(tc.when); got != tc.key {
|
|
t.Errorf("arenaSeasonKey(%s) = %s, want %s", tc.when, got, tc.key)
|
|
}
|
|
start, end := arenaSeasonBounds(tc.when)
|
|
if !start.Equal(tc.wantStart) {
|
|
t.Errorf("season start for %s = %s, want %s", tc.when, start, tc.wantStart)
|
|
}
|
|
if !end.Equal(start.AddDate(0, 3, 0)) {
|
|
t.Errorf("season end for %s is not start+3mo", tc.when)
|
|
}
|
|
if !tc.when.Before(end) || tc.when.Before(start) {
|
|
t.Errorf("%s does not fall inside its own season bounds", tc.when)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPreviousArenaSeasonWrapsYear pins the Q1 → prior-year-Q4 edge.
|
|
func TestPreviousArenaSeasonWrapsYear(t *testing.T) {
|
|
key, start, end := previousArenaSeason(time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC))
|
|
if key != "2025-Q4" {
|
|
t.Errorf("previous season = %s, want 2025-Q4", key)
|
|
}
|
|
if !start.Equal(time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("start = %s, want 2025-10-01", start)
|
|
}
|
|
if !end.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("end = %s, want 2026-01-01", end)
|
|
}
|
|
}
|
|
|
|
func insertArenaRun(t *testing.T, user id.UserID, tier, rounds int, earnings int64, outcome string, at time.Time) {
|
|
t.Helper()
|
|
_, err := db.Get().Exec(
|
|
`INSERT INTO arena_history (user_id, start_tier, tier, rounds_survived, earnings, outcome, monster_name, created_at)
|
|
VALUES (?, 1, ?, ?, ?, ?, 'Test Monster', ?)`,
|
|
string(user), tier, rounds, earnings, outcome, at.Unix())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestArenaSeasonLeaderboardWindowing: only runs inside the season window count.
|
|
func TestArenaSeasonLeaderboardWindowing(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
inSeason := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
|
start, end := arenaSeasonBounds(inSeason)
|
|
|
|
alice := id.UserID("@alice:test.invalid")
|
|
bob := id.UserID("@bob:test.invalid")
|
|
|
|
insertArenaRun(t, alice, 3, 4, 5000, "completed", inSeason)
|
|
insertArenaRun(t, alice, 5, 2, 1000, "dead", inSeason.AddDate(0, 0, 1))
|
|
insertArenaRun(t, bob, 2, 3, 9000, "cashed_out", inSeason)
|
|
// Last season — must not appear.
|
|
insertArenaRun(t, alice, 5, 4, 999999, "completed", start.AddDate(0, 0, -1))
|
|
|
|
entries, err := loadArenaSeasonLeaderboard(start, end)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("got %d entries, want 2", len(entries))
|
|
}
|
|
// Bob out-earned Alice this season (9000 vs 6000).
|
|
if entries[0].DisplayName != string(bob) {
|
|
t.Errorf("top entry = %s, want bob", entries[0].DisplayName)
|
|
}
|
|
if entries[0].TotalEarnings != 9000 {
|
|
t.Errorf("bob earnings = %d, want 9000", entries[0].TotalEarnings)
|
|
}
|
|
if entries[1].TotalEarnings != 6000 {
|
|
t.Errorf("alice earnings = %d, want 6000 (last season's 999999 must not count)", entries[1].TotalEarnings)
|
|
}
|
|
if entries[1].TotalDeaths != 1 {
|
|
t.Errorf("alice deaths = %d, want 1", entries[1].TotalDeaths)
|
|
}
|
|
if entries[1].HighestTier != 5 {
|
|
t.Errorf("alice highest tier = %d, want 5", entries[1].HighestTier)
|
|
}
|
|
}
|
|
|
|
// TestArenaSeasonChampions: earnings crown goes by total, streak crown by the
|
|
// single longest run — they can be different players.
|
|
func TestArenaSeasonChampions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
when := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
|
start, end := arenaSeasonBounds(when)
|
|
|
|
rich := id.UserID("@rich:test.invalid")
|
|
tough := id.UserID("@tough:test.invalid")
|
|
|
|
insertArenaRun(t, rich, 5, 2, 50000, "cashed_out", when)
|
|
insertArenaRun(t, tough, 1, 12, 500, "completed", when)
|
|
|
|
uid, val, ok := arenaSeasonChampion(arenaTitleEarnings, start, end)
|
|
if !ok || uid != rich || val != 50000 {
|
|
t.Errorf("earnings champion = (%s, %d, %v), want rich/50000", uid, val, ok)
|
|
}
|
|
uid, val, ok = arenaSeasonChampion(arenaTitleStreak, start, end)
|
|
if !ok || uid != tough || val != 12 {
|
|
t.Errorf("streak champion = (%s, %d, %v), want tough/12", uid, val, ok)
|
|
}
|
|
|
|
// An empty season crowns nobody.
|
|
emptyStart, emptyEnd := arenaSeasonBounds(when.AddDate(1, 0, 0))
|
|
if _, _, ok := arenaSeasonChampion(arenaTitleEarnings, emptyStart, emptyEnd); ok {
|
|
t.Error("an empty season produced an earnings champion")
|
|
}
|
|
|
|
// A season of nothing but round-zero deaths crowns no streak.
|
|
deathsOnly := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
dStart, dEnd := arenaSeasonBounds(deathsOnly)
|
|
insertArenaRun(t, tough, 1, 0, 0, "dead", deathsOnly)
|
|
if _, _, ok := arenaSeasonChampion(arenaTitleStreak, dStart, dEnd); ok {
|
|
t.Error("a season of round-zero deaths produced a streak champion")
|
|
}
|
|
}
|
|
|
|
// TestRecordArenaSeasonTitleIdempotent: the rollover must be safe to re-run.
|
|
func TestRecordArenaSeasonTitleIdempotent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
|
winner := id.UserID("@winner:test.invalid")
|
|
usurper := id.UserID("@usurper:test.invalid")
|
|
|
|
if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, winner, 100, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A second write for the same (season, kind) must not overwrite the crown.
|
|
if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, usurper, 999, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
titles, err := loadArenaSeasonTitles(winner)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(titles) != 1 {
|
|
t.Fatalf("winner has %d titles, want 1", len(titles))
|
|
}
|
|
stolen, _ := loadArenaSeasonTitles(usurper)
|
|
if len(stolen) != 0 {
|
|
t.Errorf("usurper took the crown: %v", stolen)
|
|
}
|
|
}
|