Files
termdoku/internal/database/database.go

328 lines
7.4 KiB
Go

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