package database import ( "database/sql" "fmt" "os" "path/filepath" "time" _ "modernc.org/sqlite" ) type DB struct { conn *sql.DB } func Open() (*DB, error) { home, err := os.UserHomeDir() if err != nil { return nil, err } dbDir := filepath.Join(home, ".termdoku") if err := os.MkdirAll(dbDir, 0o755); err != nil { return nil, err } dbPath := filepath.Join(dbDir, "termdoku.db") conn, err := sql.Open("sqlite", dbPath) if err != nil { return nil, err } db := &DB{conn: conn} if err := db.initialize(); err != nil { conn.Close() return nil, err } return db, nil } func (db *DB) Close() error { if db.conn != nil { return db.conn.Close() } return nil } func (db *DB) initialize() error { schema := ` CREATE TABLE IF NOT EXISTS games ( id INTEGER PRIMARY KEY AUTOINCREMENT, difficulty TEXT NOT NULL, completed BOOLEAN NOT NULL, time_seconds INTEGER NOT NULL, hints_used INTEGER NOT NULL, date DATETIME NOT NULL, is_daily BOOLEAN NOT NULL, daily_seed TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS achievements ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, icon TEXT NOT NULL, unlocked BOOLEAN NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, target INTEGER NOT NULL, unlocked_at DATETIME, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS stats ( id INTEGER PRIMARY KEY CHECK (id = 1), total_games INTEGER NOT NULL DEFAULT 0, completed_games INTEGER NOT NULL DEFAULT 0, current_streak INTEGER NOT NULL DEFAULT 0, best_streak INTEGER NOT NULL DEFAULT 0, last_played_date TEXT, hints_used INTEGER NOT NULL DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS best_times ( difficulty TEXT PRIMARY KEY, time_seconds INTEGER NOT NULL, hints_used INTEGER NOT NULL, achieved_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_games_difficulty ON games(difficulty); CREATE INDEX IF NOT EXISTS idx_games_date ON games(date); CREATE INDEX IF NOT EXISTS idx_games_daily_seed ON games(daily_seed); ` _, err := db.conn.Exec(schema) if err != nil { return fmt.Errorf("failed to initialize schema: %w", err) } var count int err = db.conn.QueryRow("SELECT COUNT(*) FROM stats").Scan(&count) if err != nil { return err } if count == 0 { _, err = db.conn.Exec("INSERT INTO stats (id) VALUES (1)") if err != nil { return err } } return nil } type GameRecord struct { ID int Difficulty string Completed bool TimeSeconds int HintsUsed int Date time.Time IsDaily bool DailySeed string } func (db *DB) SaveGame(record GameRecord) error { query := ` INSERT INTO games (difficulty, completed, time_seconds, hints_used, date, is_daily, daily_seed) VALUES (?, ?, ?, ?, ?, ?, ?) ` _, err := db.conn.Exec(query, record.Difficulty, record.Completed, record.TimeSeconds, record.HintsUsed, record.Date, record.IsDaily, record.DailySeed, ) return err } func (db *DB) GetLeaderboard(difficulty string, limit int) ([]GameRecord, error) { query := ` SELECT id, difficulty, completed, time_seconds, hints_used, date, is_daily, daily_seed FROM games WHERE difficulty = ? AND completed = 1 ORDER BY time_seconds ASC, hints_used ASC LIMIT ? ` rows, err := db.conn.Query(query, difficulty, limit) if err != nil { return nil, err } defer rows.Close() var records []GameRecord for rows.Next() { var r GameRecord var dailySeed sql.NullString err := rows.Scan(&r.ID, &r.Difficulty, &r.Completed, &r.TimeSeconds, &r.HintsUsed, &r.Date, &r.IsDaily, &dailySeed) if err != nil { return nil, err } if dailySeed.Valid { r.DailySeed = dailySeed.String } records = append(records, r) } return records, rows.Err() } type Stats struct { TotalGames int CompletedGames int CurrentStreak int BestStreak int LastPlayedDate string HintsUsed int } func (db *DB) GetStats() (*Stats, error) { query := "SELECT total_games, completed_games, current_streak, best_streak, last_played_date, hints_used FROM stats WHERE id = 1" var stats Stats var lastPlayed sql.NullString err := db.conn.QueryRow(query).Scan( &stats.TotalGames, &stats.CompletedGames, &stats.CurrentStreak, &stats.BestStreak, &lastPlayed, &stats.HintsUsed, ) if err != nil { return nil, err } if lastPlayed.Valid { stats.LastPlayedDate = lastPlayed.String } return &stats, nil } func (db *DB) UpdateStats(stats *Stats) error { query := ` UPDATE stats SET total_games = ?, completed_games = ?, current_streak = ?, best_streak = ?, last_played_date = ?, hints_used = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 ` _, err := db.conn.Exec(query, stats.TotalGames, stats.CompletedGames, stats.CurrentStreak, stats.BestStreak, stats.LastPlayedDate, stats.HintsUsed, ) return err } type Achievement struct { ID string Name string Description string Icon string Unlocked bool Progress int Target int UnlockedAt *time.Time } func (db *DB) GetAchievements() (map[string]*Achievement, error) { query := "SELECT id, name, description, icon, unlocked, progress, target, unlocked_at FROM achievements" rows, err := db.conn.Query(query) if err != nil { return nil, err } defer rows.Close() achievements := make(map[string]*Achievement) for rows.Next() { var a Achievement var unlockedAt sql.NullTime err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Icon, &a.Unlocked, &a.Progress, &a.Target, &unlockedAt) if err != nil { return nil, err } if unlockedAt.Valid { a.UnlockedAt = &unlockedAt.Time } achievements[a.ID] = &a } return achievements, rows.Err() } func (db *DB) SaveAchievement(ach *Achievement) error { query := ` INSERT OR REPLACE INTO achievements (id, name, description, icon, unlocked, progress, target, unlocked_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ` _, err := db.conn.Exec(query, ach.ID, ach.Name, ach.Description, ach.Icon, ach.Unlocked, ach.Progress, ach.Target, ach.UnlockedAt, ) return err } func (db *DB) GetBestTime(difficulty string) (int, bool, error) { query := "SELECT time_seconds FROM best_times WHERE difficulty = ?" var timeSeconds int err := db.conn.QueryRow(query, difficulty).Scan(&timeSeconds) if err == sql.ErrNoRows { return 0, false, nil } if err != nil { return 0, false, err } return timeSeconds, true, nil } func (db *DB) UpdateBestTime(difficulty string, timeSeconds int, hintsUsed int) error { query := ` INSERT OR REPLACE INTO best_times (difficulty, time_seconds, hints_used, achieved_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ` _, err := db.conn.Exec(query, difficulty, timeSeconds, hintsUsed) return err } func (db *DB) GetRecentGames(limit int) ([]GameRecord, error) { query := ` SELECT id, difficulty, completed, time_seconds, hints_used, date, is_daily, daily_seed FROM games ORDER BY date DESC LIMIT ? ` rows, err := db.conn.Query(query, limit) if err != nil { return nil, err } defer rows.Close() var records []GameRecord for rows.Next() { var r GameRecord var dailySeed sql.NullString err := rows.Scan(&r.ID, &r.Difficulty, &r.Completed, &r.TimeSeconds, &r.HintsUsed, &r.Date, &r.IsDaily, &dailySeed) if err != nil { return nil, err } if dailySeed.Valid { r.DailySeed = dailySeed.String } records = append(records, r) } return records, rows.Err() }