Files
Vantz Stockwell 651a35d4be
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s
docs(reference): import Dune: Awakening server-manager references
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.

- icehunter/    dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
                closest analog to our agent's Dune docker control plane (compose
                lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/  Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
                daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
                Hyper-V self-host path + game-config schema

See docs/reference-repos/README.md for the full index + how we use each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:08:05 -04:00

483 lines
15 KiB
Go

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)
}