package main import ( "context" "database/sql" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" _ "modernc.org/sqlite" ) var globalSessionDB *sql.DB type sessionStats struct { TotalPlaytimeSecs int64 `json:"total_playtime_secs"` SessionCount int64 `json:"session_count"` AvgSessionSecs int64 `json:"avg_session_secs"` } func resolveSessionDBPath() string { if p := os.Getenv("DUNE_ADMIN_SESSIONS_DB"); p != "" { return p } return filepath.Join(configDir(), "sessions.db") } func openSessionDB(path string) (*sql.DB, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open session db: %w", err) } if err := initSessionSchema(db); err != nil { _ = db.Close() return nil, fmt.Errorf("init session schema: %w", err) } return db, nil } func initSessionSchema(db *sql.DB) error { if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS play_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, started_at TEXT NOT NULL, ended_at TEXT, duration_secs INTEGER ); CREATE INDEX IF NOT EXISTS idx_ps_account ON play_sessions(account_id); CREATE TABLE IF NOT EXISTS stat_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, snapped_at TEXT NOT NULL, char_xp INTEGER, skill_points INTEGER, intel_points INTEGER, combat_xp INTEGER, crafting_xp INTEGER, gathering_xp INTEGER, exploration_xp INTEGER, sabotage_xp INTEGER, solaris_balance INTEGER ); CREATE INDEX IF NOT EXISTS idx_ss_account ON stat_snapshots(account_id, snapped_at); `); err != nil { return err } // Migration: add solaris_balance to pre-existing databases that lack it. // SQLite returns a "duplicate column name" error if it already exists (e.g. // on a fresh DB where CREATE TABLE above already includes the column). _, err := db.Exec(`ALTER TABLE stat_snapshots ADD COLUMN solaris_balance INTEGER`) if err != nil && !strings.Contains(err.Error(), "duplicate column name") { return fmt.Errorf("migrate stat_snapshots.solaris_balance: %w", err) } return nil } // closeOrphanedSessions marks sessions left open by a previous run. Duration // is set to 0 since we don't know when the player actually logged off. func closeOrphanedSessions(db *sql.DB) error { _, err := db.Exec(` UPDATE play_sessions SET ended_at = started_at, duration_secs = 0 WHERE ended_at IS NULL `) return err } // recordSessions compares onlineIDs against currently open sessions in the // SQLite store and opens/closes sessions as needed. func recordSessions(ctx context.Context, onlineIDs []int64, db *sql.DB) error { openSessions, err := queryOpenSessions(ctx, db) if err != nil { return err } onlineSet := make(map[int64]bool, len(onlineIDs)) for _, id := range onlineIDs { onlineSet[id] = true } now := time.Now().UTC().Format(time.RFC3339) for _, id := range onlineIDs { if !openSessions[id] { if _, err := db.ExecContext(ctx, `INSERT INTO play_sessions(account_id, started_at) VALUES(?, ?)`, id, now, ); err != nil { return fmt.Errorf("start session for account %d: %w", id, err) } } } for id := range openSessions { if !onlineSet[id] { if _, err := db.ExecContext(ctx, ` UPDATE play_sessions SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER) WHERE account_id = ? AND ended_at IS NULL`, now, now, id, ); err != nil { return fmt.Errorf("close session for account %d: %w", id, err) } } } return nil } func queryOpenSessions(ctx context.Context, db *sql.DB) (map[int64]bool, error) { rows, err := db.QueryContext(ctx, `SELECT account_id FROM play_sessions WHERE ended_at IS NULL`) if err != nil { return nil, fmt.Errorf("query open sessions: %w", err) } defer func() { _ = rows.Close() }() open := make(map[int64]bool) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan open session: %w", err) } open[id] = true } return open, rows.Err() } func getSessionStats(ctx context.Context, db *sql.DB, accountID int64) (sessionStats, error) { row := db.QueryRowContext(ctx, ` SELECT COALESCE(SUM(duration_secs), 0), COUNT(*), COALESCE(AVG(duration_secs), 0.0) FROM play_sessions WHERE account_id = ? AND ended_at IS NOT NULL `, accountID) var stats sessionStats var avg float64 if err := row.Scan(&stats.TotalPlaytimeSecs, &stats.SessionCount, &avg); err != nil { return sessionStats{}, fmt.Errorf("get session stats for account %d: %w", accountID, err) } stats.AvgSessionSecs = int64(avg) return stats, nil } // startSessionTracking opens the session DB and starts the poller. Returns a // cancel func that stops the poller. Errors are logged, not fatal. func startSessionTracking() context.CancelFunc { ctx, cancel := context.WithCancel(context.Background()) if err := initSessionPoller(ctx); err != nil { log.Printf("session tracker: %v", err) } return cancel } // initSessionPoller initialises the session database and starts the background // polling goroutine. When globalStore is available the unified handle is used // directly; otherwise a dedicated file is opened (legacy / test mode). Skips // gracefully when globalDB is not yet connected so the server starts in // degraded mode. func initSessionPoller(ctx context.Context) error { var sdb *sql.DB if globalStore != nil { sdb = globalStore } else { var err error sdb, err = openSessionDB(resolveSessionDBPath()) if err != nil { return fmt.Errorf("open session db: %w", err) } } if err := closeOrphanedSessions(sdb); err != nil { log.Printf("session poller: close orphaned sessions: %v", err) } globalSessionDB = sdb if globalDB == nil { log.Printf("session poller: DB not connected, skipping poll loop") return nil } go startSessionPoller(ctx, globalDB, sdb, 5*time.Minute) return nil } type sessionRecord struct { StartedAt string `json:"started_at"` EndedAt string `json:"ended_at"` DurationSecs int64 `json:"duration_secs"` } func getSessionHistory(ctx context.Context, db *sql.DB, accountID int64, limit int) ([]sessionRecord, error) { rows, err := db.QueryContext(ctx, ` SELECT started_at, ended_at, duration_secs FROM play_sessions WHERE account_id = ? AND ended_at IS NOT NULL ORDER BY started_at ASC LIMIT ? `, accountID, limit) if err != nil { return nil, fmt.Errorf("query session history for account %d: %w", accountID, err) } defer func() { _ = rows.Close() }() var out []sessionRecord for rows.Next() { var r sessionRecord if err := rows.Scan(&r.StartedAt, &r.EndedAt, &r.DurationSecs); err != nil { return nil, fmt.Errorf("scan session record: %w", err) } out = append(out, r) } if out == nil { out = []sessionRecord{} } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate session history for account %d: %w", accountID, err) } return out, nil } type statSnapshot struct { AccountID int64 `json:"account_id"` SnappedAt string `json:"snapped_at"` CharXP *int64 `json:"char_xp"` SkillPoints *int `json:"skill_points"` IntelPoints *int `json:"intel_points"` CombatXP *int `json:"combat_xp"` CraftingXP *int `json:"crafting_xp"` GatheringXP *int `json:"gathering_xp"` ExplorationXP *int `json:"exploration_xp"` SabotageXP *int `json:"sabotage_xp"` SolarisBalance *int64 `json:"solaris_balance"` } func writeStatSnapshot(ctx context.Context, sdb *sql.DB, snap statSnapshot) error { _, err := sdb.ExecContext(ctx, ` INSERT INTO stat_snapshots( account_id, snapped_at, char_xp, skill_points, intel_points, combat_xp, crafting_xp, gathering_xp, exploration_xp, sabotage_xp, solaris_balance ) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, snap.AccountID, snap.SnappedAt, snap.CharXP, snap.SkillPoints, snap.IntelPoints, snap.CombatXP, snap.CraftingXP, snap.GatheringXP, snap.ExplorationXP, snap.SabotageXP, snap.SolarisBalance, ) if err != nil { return fmt.Errorf("write stat snapshot for account %d: %w", snap.AccountID, err) } return nil } func getStatSnapshotHistory(ctx context.Context, sdb *sql.DB, accountID int64, limit int) ([]statSnapshot, error) { rows, err := sdb.QueryContext(ctx, ` SELECT snapped_at, char_xp, skill_points, intel_points, combat_xp, crafting_xp, gathering_xp, exploration_xp, sabotage_xp, solaris_balance FROM stat_snapshots WHERE account_id = ? ORDER BY snapped_at ASC LIMIT ? `, accountID, limit) if err != nil { return nil, fmt.Errorf("query stat snapshot history for account %d: %w", accountID, err) } defer func() { _ = rows.Close() }() var out []statSnapshot for rows.Next() { s := statSnapshot{AccountID: accountID} if err := rows.Scan(&s.SnappedAt, &s.CharXP, &s.SkillPoints, &s.IntelPoints, &s.CombatXP, &s.CraftingXP, &s.GatheringXP, &s.ExplorationXP, &s.SabotageXP, &s.SolarisBalance, ); err != nil { return nil, fmt.Errorf("scan stat snapshot: %w", err) } out = append(out, s) } if out == nil { out = []statSnapshot{} } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate stat snapshots for account %d: %w", accountID, err) } return out, nil } // daySnap is one account's latest stat snapshot on a given UTC day, used by the // faction-growth trend (#130 ext). Solaris/XP default to 0 when NULL. type daySnap struct { AccountID int64 Day string Solaris int64 CharXP int64 } // getDailySnapshots returns the latest snapshot per (account, UTC day) within // the last `days` days — one row per account per day, so a day's total isn't // inflated by the 5-minute poll cadence. func getDailySnapshots(ctx context.Context, db *sql.DB, days int) ([]daySnap, error) { if days < 1 { days = 1 } since := time.Now().UTC().AddDate(0, 0, -(days - 1)).Format("2006-01-02") rows, err := db.QueryContext(ctx, ` SELECT account_id, day, solaris, xp FROM ( SELECT account_id, substr(snapped_at, 1, 10) AS day, COALESCE(solaris_balance, 0) AS solaris, COALESCE(char_xp, 0) AS xp, ROW_NUMBER() OVER (PARTITION BY account_id, substr(snapped_at, 1, 10) ORDER BY snapped_at DESC) AS rn FROM stat_snapshots WHERE substr(snapped_at, 1, 10) >= ? ) WHERE rn = 1 ORDER BY day`, since) if err != nil { return nil, fmt.Errorf("query daily snapshots: %w", err) } defer func() { _ = rows.Close() }() var out []daySnap for rows.Next() { var d daySnap if err := rows.Scan(&d.AccountID, &d.Day, &d.Solaris, &d.CharXP); err != nil { return nil, fmt.Errorf("scan daily snapshot: %w", err) } out = append(out, d) } return out, rows.Err() } func pollOnce(ctx context.Context, pool *pgxpool.Pool, db *sql.DB) { onlineIDs, err := cmdFetchOnlineAccountIDs(ctx, pool) if err != nil { log.Printf("session poller: fetch online players: %v", err) return } if err := recordSessions(ctx, onlineIDs, db); err != nil { log.Printf("session poller: record sessions: %v", err) } snappedAt := time.Now().UTC().Format(time.RFC3339) for _, accountID := range onlineIDs { snap, err := cmdFetchPlayerSnapshot(ctx, pool, accountID, snappedAt) if err != nil { log.Printf("session poller: snapshot account %d: %v", accountID, err) continue } if err := writeStatSnapshot(ctx, db, snap); err != nil { log.Printf("session poller: write snapshot account %d: %v", accountID, err) } } } func startSessionPoller(ctx context.Context, pool *pgxpool.Pool, db *sql.DB, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: pollOnce(ctx, pool, db) } } } // activityPoint is one day's session count for the server-wide activity trend // on the Players dashboard (#130). Day is "YYYY-MM-DD" in UTC. type activityPoint struct { Day string `json:"day"` Count int64 `json:"count"` } // getServerPlaytimeSecs sums completed-session duration across all players. func getServerPlaytimeSecs(ctx context.Context, db *sql.DB) (int64, error) { var total int64 row := db.QueryRowContext(ctx, `SELECT COALESCE(SUM(duration_secs), 0) FROM play_sessions WHERE ended_at IS NOT NULL`) if err := row.Scan(&total); err != nil { return 0, fmt.Errorf("server playtime: %w", err) } return total, nil } // getActivityTrendCounts returns a sparse day->session-count map for sessions // started on or after sinceDay ("YYYY-MM-DD", UTC). started_at is RFC3339, so // its first 10 chars are the UTC date — substr keeps the day-bucket comparable. func getActivityTrendCounts(ctx context.Context, db *sql.DB, sinceDay string) (map[string]int64, error) { rows, err := db.QueryContext(ctx, ` SELECT substr(started_at, 1, 10) AS day, COUNT(*) FROM play_sessions WHERE substr(started_at, 1, 10) >= ? GROUP BY day`, sinceDay) if err != nil { return nil, fmt.Errorf("query activity trend: %w", err) } defer func() { _ = rows.Close() }() counts := make(map[string]int64) for rows.Next() { var day string var n int64 if err := rows.Scan(&day, &n); err != nil { return nil, fmt.Errorf("scan activity trend: %w", err) } counts[day] = n } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate activity trend: %w", err) } return counts, nil } // fillActivityTrend turns a sparse day->count map into a contiguous, ascending // series of `days` points ending on today (UTC), zero-filling inactive days so // the dashboard chart shows gaps as 0. today is a parameter (not time.Now) so // the logic is deterministic and unit-testable. func fillActivityTrend(days int, today time.Time, counts map[string]int64) []activityPoint { if days < 1 { days = 1 } out := make([]activityPoint, 0, days) start := today.AddDate(0, 0, -(days - 1)) for i := 0; i < days; i++ { day := start.AddDate(0, 0, i).Format("2006-01-02") out = append(out, activityPoint{Day: day, Count: counts[day]}) } return out } // sessionSummary returns the session-derived dashboard fields: total playtime // and a zero-filled `days`-day activity trend. db may be nil (session tracking // disabled) — then playtime is 0 and the trend is all zeros. Query failures are // logged, not fatal: the dashboard degrades gracefully. func sessionSummary(ctx context.Context, db *sql.DB, days int) (int64, []activityPoint) { now := time.Now().UTC() if db == nil { return 0, fillActivityTrend(days, now, map[string]int64{}) } var playtime int64 if pt, err := getServerPlaytimeSecs(ctx, db); err != nil { log.Printf("sessionSummary: playtime: %v", err) } else { playtime = pt } since := now.AddDate(0, 0, -(days - 1)).Format("2006-01-02") counts, err := getActivityTrendCounts(ctx, db, since) if err != nil { log.Printf("sessionSummary: trend: %v", err) counts = map[string]int64{} } return playtime, fillActivityTrend(days, now, counts) }