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>
483 lines
15 KiB
Go
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)
|
|
}
|