328 lines
7.4 KiB
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()
|
|
}
|