(Feat): Initial Commit, Termdoku
This commit is contained in:
327
internal/database/database.go
Normal file
327
internal/database/database.go
Normal file
@@ -0,0 +1,327 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user