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.
226 lines
7.4 KiB
Go
226 lines
7.4 KiB
Go
package plugin
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// ── Arena seasons ───────────────────────────────────────────────────────────
|
|
//
|
|
// Quarterly standings (gogobee_engagement_plan.md C4). The plan called for a
|
|
// quarterly *reset* of arena_stats; this derives season standings from
|
|
// arena_history.created_at instead. Same player-visible effect — the board
|
|
// clears every quarter — but lifetime totals survive for `!arena stats`, and
|
|
// there is no destructive job that can fire twice or half-way.
|
|
//
|
|
// Season titles are archived to their own table rather than player_meta.title:
|
|
// that column already carries the Survivalist milestone, and a season champion
|
|
// overwriting someone's expedition title would silently destroy it.
|
|
|
|
// arenaSeasonTitleKinds are the two crowns awarded per season.
|
|
const (
|
|
arenaTitleEarnings = "earnings"
|
|
arenaTitleStreak = "streak"
|
|
)
|
|
|
|
// arenaSeasonKey names the quarter containing t, e.g. "2026-Q3".
|
|
func arenaSeasonKey(t time.Time) string {
|
|
t = t.UTC()
|
|
return fmt.Sprintf("%d-Q%d", t.Year(), (int(t.Month())-1)/3+1)
|
|
}
|
|
|
|
// arenaSeasonStart is the first instant of the quarter containing t.
|
|
func arenaSeasonStart(t time.Time) time.Time {
|
|
t = t.UTC()
|
|
firstMonth := time.Month(((int(t.Month())-1)/3)*3 + 1)
|
|
return time.Date(t.Year(), firstMonth, 1, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
// arenaSeasonBounds returns [start, end) for the quarter containing t.
|
|
func arenaSeasonBounds(t time.Time) (time.Time, time.Time) {
|
|
start := arenaSeasonStart(t)
|
|
return start, start.AddDate(0, 3, 0)
|
|
}
|
|
|
|
// previousArenaSeason returns the key and bounds of the quarter before t's.
|
|
func previousArenaSeason(t time.Time) (string, time.Time, time.Time) {
|
|
prev := arenaSeasonStart(t).AddDate(0, -1, 0) // any instant inside the prior quarter
|
|
start, end := arenaSeasonBounds(prev)
|
|
return arenaSeasonKey(prev), start, end
|
|
}
|
|
|
|
// loadArenaSeasonLeaderboard aggregates arena_history over [start, end) into
|
|
// the same shape the lifetime board renders.
|
|
func loadArenaSeasonLeaderboard(start, end time.Time) ([]ArenaLeaderboardEntry, error) {
|
|
rows, err := db.Get().Query(`
|
|
SELECT h.user_id, COALESCE(c.display_name, h.user_id),
|
|
SUM(h.earnings),
|
|
MAX(h.tier),
|
|
SUM(CASE WHEN h.tier = 5 AND h.outcome = 'completed' THEN 1 ELSE 0 END),
|
|
COUNT(*),
|
|
SUM(CASE WHEN h.outcome = 'dead' THEN 1 ELSE 0 END)
|
|
FROM arena_history h
|
|
LEFT JOIN player_meta c ON c.user_id = h.user_id
|
|
WHERE h.created_at >= ? AND h.created_at < ?
|
|
GROUP BY h.user_id
|
|
ORDER BY SUM(h.earnings) DESC
|
|
LIMIT 10`, start.Unix(), end.Unix())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []ArenaLeaderboardEntry
|
|
for rows.Next() {
|
|
var e ArenaLeaderboardEntry
|
|
var uid string
|
|
if err := rows.Scan(&uid, &e.DisplayName, &e.TotalEarnings, &e.HighestTier,
|
|
&e.Tier5Completions, &e.TotalRuns, &e.TotalDeaths); err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
// arenaSeasonChampion finds the single top row for a season by the given
|
|
// metric. Returns ok=false when nobody entered the arena that quarter.
|
|
func arenaSeasonChampion(kind string, start, end time.Time) (id.UserID, int64, bool) {
|
|
var metric string
|
|
switch kind {
|
|
case arenaTitleEarnings:
|
|
metric = "SUM(earnings)"
|
|
case arenaTitleStreak:
|
|
metric = "MAX(rounds_survived)"
|
|
default:
|
|
return "", 0, false
|
|
}
|
|
|
|
// Only runs that earned or survived something can crown anyone: a season of
|
|
// nothing but deaths at round zero should award no streak title.
|
|
q := fmt.Sprintf(`
|
|
SELECT user_id, %s AS metric
|
|
FROM arena_history
|
|
WHERE created_at >= ? AND created_at < ?
|
|
GROUP BY user_id
|
|
HAVING metric > 0
|
|
ORDER BY metric DESC, user_id ASC
|
|
LIMIT 1`, metric)
|
|
|
|
var uid string
|
|
var value int64
|
|
err := db.Get().QueryRow(q, start.Unix(), end.Unix()).Scan(&uid, &value)
|
|
if err == sql.ErrNoRows {
|
|
return "", 0, false
|
|
}
|
|
if err != nil {
|
|
slog.Error("arena season: champion query", "kind", kind, "err", err)
|
|
return "", 0, false
|
|
}
|
|
return id.UserID(uid), value, true
|
|
}
|
|
|
|
// recordArenaSeasonTitle archives a crown. Idempotent on (season, kind).
|
|
func recordArenaSeasonTitle(season, kind string, userID id.UserID, value int64, at time.Time) error {
|
|
_, err := db.Get().Exec(`
|
|
INSERT INTO arena_season_titles (season, kind, user_id, value, awarded_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(season, kind) DO NOTHING`,
|
|
season, kind, string(userID), value, at.Unix())
|
|
return err
|
|
}
|
|
|
|
// loadArenaSeasonTitles returns every crown a player has ever taken.
|
|
func loadArenaSeasonTitles(userID id.UserID) ([]string, error) {
|
|
rows, err := db.Get().Query(`
|
|
SELECT season, kind FROM arena_season_titles
|
|
WHERE user_id = ? ORDER BY season DESC`, string(userID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var season, kind string
|
|
if err := rows.Scan(&season, &kind); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, fmt.Sprintf("%s %s", season, arenaTitleName(kind)))
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func arenaTitleName(kind string) string {
|
|
switch kind {
|
|
case arenaTitleEarnings:
|
|
return "Coinlord of the Arena"
|
|
case arenaTitleStreak:
|
|
return "Longest Walk"
|
|
}
|
|
return kind
|
|
}
|
|
|
|
// ── Rollover ────────────────────────────────────────────────────────────────
|
|
|
|
// arenaSeasonRollover awards the previous season's crowns exactly once, and
|
|
// announces them. Safe to call every midnight: JobCompleted keyed on the season
|
|
// makes it a no-op for the rest of the quarter, and a bot that was down on the
|
|
// rollover day still catches up the next time it wakes.
|
|
func (p *AdventurePlugin) arenaSeasonRollover(now time.Time) {
|
|
season, start, end := previousArenaSeason(now)
|
|
jobName := "arena_season_rollover"
|
|
if db.JobCompleted(jobName, season) {
|
|
return
|
|
}
|
|
// Guard against awarding a season that hasn't finished yet — only possible
|
|
// if a caller passes a doctored clock.
|
|
if !now.UTC().After(end) {
|
|
return
|
|
}
|
|
|
|
var lines []string
|
|
for _, kind := range []string{arenaTitleEarnings, arenaTitleStreak} {
|
|
uid, value, ok := arenaSeasonChampion(kind, start, end)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := recordArenaSeasonTitle(season, kind, uid, value, now); err != nil {
|
|
slog.Error("arena season: record title failed", "season", season, "kind", kind, "err", err)
|
|
continue // don't announce a crown we failed to persist
|
|
}
|
|
name, _ := loadDisplayName(uid)
|
|
switch kind {
|
|
case arenaTitleEarnings:
|
|
lines = append(lines, fmt.Sprintf("🥇 **%s** — _%s_ (€%d earned)",
|
|
name, arenaTitleName(kind), value))
|
|
case arenaTitleStreak:
|
|
lines = append(lines, fmt.Sprintf("🏃 **%s** — _%s_ (%d rounds in one run)",
|
|
name, arenaTitleName(kind), value))
|
|
}
|
|
}
|
|
|
|
db.MarkJobCompleted(jobName, season)
|
|
if len(lines) == 0 {
|
|
slog.Info("arena season: closed with no entrants", "season", season)
|
|
return
|
|
}
|
|
slog.Info("arena season: crowned", "season", season, "titles", len(lines))
|
|
|
|
gr := gamesRoom()
|
|
if gr == "" {
|
|
return
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("⚔️ **Arena Season %s has ended.**\n\n", season))
|
|
sb.WriteString(strings.Join(lines, "\n"))
|
|
sb.WriteString("\n\nThe board is clear. Season " + arenaSeasonKey(now) + " starts now.")
|
|
p.SendMessage(id.RoomID(gr), sb.String())
|
|
}
|