mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Five bugs found reviewing n1-restoration end to end. beginCombatTurn settles any phase the engine owes before reading whose turn it is. That settle can end the fight — and the old code then answered "you're not in a fight" and returned. The terminal status was already persisted, so nothing ever paid the party out: no XP, no loot, no death recorded, no run teardown. The reaper cannot recover it either, because listExpiredCombatSessions filters on status='active'. Close the fight out there, the way the !fight start path and the reaper already do. A party member was permanently soft-locked when their leader extracted and never resumed. seatedExpeditionFor (the guard) spans 'extracting'; expeditionForMember (what !expedition leave resolved through) saw only 'active'. So the member was refused any new adventure by the guard and told "No active expedition" by the command the guard points them at, with nothing sweeping stale rows and only the leader able to clear one. Resolve the exit through the same lookup as the gate. updateSupplies overwrites supplies_json wholesale, and expeditionCmdAccept folded a member's packs onto a snapshot read before the coin debit, unlocked. Handlers run one goroutine per event, so two invitees accepting genuinely interleave and one member's packs vanish. advUserLock cannot help — it is keyed by sender, so racing members take different mutexes. Add advExpeditionLock and re-read the pool under it. Closes accept-vs-accept; the six other updateSupplies callers still race and are written up separately. runHarvestInterrupt picked an elite enemy and elite narration off a local `elite` flag, then passed a hardcoded false as isElite to closeOutZoneWin. dropZoneLoot gates masterwork on isBoss||isElite, so beating an elite interrupt skipped the masterwork roll and took standard treasure weight — while the same elite fought via !zone paid out correctly. arenaSeasonRollover marked its job complete even when recordArenaSeasonTitle failed, and JobCompleted short-circuits every later run for that quarter, so a transient SQLite BUSY lost the crown forever. Defer completion on failure; the insert is ON CONFLICT DO NOTHING against PRIMARY KEY (season, kind) and a past season's data is frozen, so the retry is safe. Also: drop dead partySurvivors, collapse the zoneCombatRoster alias into fightRoster, route partyCasualtyLine through joinNames, fold four copies of the expedition column projection into expeditionSelectCols, stop replyDM sending a blank DM, and correct two doc comments describing a path that no longer exists. Deliberately not fixed, with reasons, in gogobee_code_review_followups.md — most notably that both turn-based close-outs skip grantCombatAchievements and persistDnDPostCombatSubclass, which the auto-resolve paths run.
236 lines
7.9 KiB
Go
236 lines
7.9 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
|
|
failed := false
|
|
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)
|
|
failed = true
|
|
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))
|
|
}
|
|
}
|
|
|
|
// Marking the job done is what stops the next midnight from retrying, so a
|
|
// crown we failed to persist must not mark it. recordArenaSeasonTitle is
|
|
// idempotent on (season, kind), and a past season's data is frozen, so the
|
|
// retry re-derives the same champions and no-ops the ones already stored.
|
|
if failed {
|
|
slog.Warn("arena season: deferring completion after title failure", "season", season)
|
|
return
|
|
}
|
|
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())
|
|
}
|