(Feat): Initial Commit, Termdoku
This commit is contained in:
162
internal/achievements/achievements.go
Normal file
162
internal/achievements/achievements.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package achievements
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Achievement struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Unlocked bool `json:"unlocked"`
|
||||
Progress int `json:"progress"`
|
||||
Target int `json:"target"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
Achievements map[string]*Achievement `json:"achievements"`
|
||||
NewUnlocks []string `json:"-"`
|
||||
}
|
||||
|
||||
func New() *Manager {
|
||||
return &Manager{
|
||||
Achievements: map[string]*Achievement{
|
||||
"first_win": {
|
||||
ID: "first_win",
|
||||
Name: "First Victory",
|
||||
Description: "Complete your first puzzle",
|
||||
Icon: "🏆",
|
||||
Target: 1,
|
||||
},
|
||||
"speed_demon": {
|
||||
ID: "speed_demon",
|
||||
Name: "Speed Demon",
|
||||
Description: "Complete an Easy puzzle in under 3 minutes",
|
||||
Icon: "⚡",
|
||||
Target: 1,
|
||||
},
|
||||
"perfectionist": {
|
||||
ID: "perfectionist",
|
||||
Name: "Perfectionist",
|
||||
Description: "Complete a puzzle without using hints",
|
||||
Icon: "💎",
|
||||
Target: 1,
|
||||
},
|
||||
"streak_master": {
|
||||
ID: "streak_master",
|
||||
Name: "Streak Master",
|
||||
Description: "Achieve a 5-day streak",
|
||||
Icon: "🔥",
|
||||
Target: 5,
|
||||
},
|
||||
"century": {
|
||||
ID: "century",
|
||||
Name: "Century Club",
|
||||
Description: "Complete 100 puzzles",
|
||||
Icon: "💯",
|
||||
Target: 100,
|
||||
},
|
||||
"lunatic_legend": {
|
||||
ID: "lunatic_legend",
|
||||
Name: "Lunatic Legend",
|
||||
Description: "Complete 10 Lunatic puzzles",
|
||||
Icon: "🌙",
|
||||
Target: 10,
|
||||
},
|
||||
"daily_devotee": {
|
||||
ID: "daily_devotee",
|
||||
Name: "Daily Devotee",
|
||||
Description: "Complete 30 daily puzzles",
|
||||
Icon: "📅",
|
||||
Target: 30,
|
||||
},
|
||||
"no_mistakes": {
|
||||
ID: "no_mistakes",
|
||||
Name: "Flawless",
|
||||
Description: "Complete a Hard puzzle without auto-check",
|
||||
Icon: "✨",
|
||||
Target: 1,
|
||||
},
|
||||
},
|
||||
NewUnlocks: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func path() (string, error) {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(h, ".termdoku", "achievements.json"), nil
|
||||
}
|
||||
|
||||
func Load() (*Manager, error) {
|
||||
m := New()
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return m, nil
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
if err := json.Unmarshal(b, m); err != nil {
|
||||
return m, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func Save(m *Manager) error {
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
|
||||
func (m *Manager) CheckAndUnlock(id string, progress int) bool {
|
||||
if ach, ok := m.Achievements[id]; ok {
|
||||
if !ach.Unlocked {
|
||||
ach.Progress = progress
|
||||
if ach.Progress >= ach.Target {
|
||||
ach.Unlocked = true
|
||||
m.NewUnlocks = append(m.NewUnlocks, id)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) GetUnlockedCount() int {
|
||||
count := 0
|
||||
for _, ach := range m.Achievements {
|
||||
if ach.Unlocked {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (m *Manager) GetTotalCount() int {
|
||||
return len(m.Achievements)
|
||||
}
|
||||
|
||||
func (m *Manager) ClearNewUnlocks() {
|
||||
m.NewUnlocks = []string{}
|
||||
}
|
||||
68
internal/config/config.go
Normal file
68
internal/config/config.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Theme string `yaml:"theme"`
|
||||
AutoCheck bool `yaml:"autoCheck"`
|
||||
TimerEnabled bool `yaml:"timerEnabled"`
|
||||
Bindings map[string][]string `yaml:"bindings"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Theme: "dark",
|
||||
AutoCheck: true,
|
||||
TimerEnabled: true,
|
||||
Bindings: map[string][]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func path() (string, error) {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(h, ".termdoku", "config.yaml"), nil
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Default()
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
if err := yaml.Unmarshal(b, &cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Save(cfg Config) error {
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
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()
|
||||
}
|
||||
149
internal/game/board.go
Normal file
149
internal/game/board.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Grid [9][9]uint8
|
||||
|
||||
type Move struct {
|
||||
Row int
|
||||
Col int
|
||||
Prev uint8
|
||||
Next uint8
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type Board struct {
|
||||
Given [9][9]bool
|
||||
Values Grid
|
||||
}
|
||||
|
||||
func NewBoardFromPuzzle(p Grid) Board {
|
||||
var b Board
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
v := p[r][c]
|
||||
if v != 0 {
|
||||
b.Given[r][c] = true
|
||||
}
|
||||
b.Values[r][c] = v
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Board) IsGiven(row, col int) bool { return b.Given[row][col] }
|
||||
|
||||
func (b *Board) SetValue(row, col int, v uint8) (prev uint8, ok bool) {
|
||||
if b.Given[row][col] {
|
||||
return b.Values[row][col], false
|
||||
}
|
||||
prev = b.Values[row][col]
|
||||
b.Values[row][col] = v
|
||||
return prev, true
|
||||
}
|
||||
|
||||
func InBounds(row, col int) bool { return row >= 0 && row < 9 && col >= 0 && col < 9 }
|
||||
|
||||
// DuplicateMap marks cells that duplicate the selected cell's value across row/col/box.
|
||||
func DuplicateMap(g Grid, selRow, selCol int) [9][9]bool {
|
||||
var dup [9][9]bool
|
||||
v := g[selRow][selCol]
|
||||
if v == 0 {
|
||||
return dup
|
||||
}
|
||||
for i := 0; i < 9; i++ {
|
||||
if g[selRow][i] == v && i != selCol {
|
||||
dup[selRow][i] = true
|
||||
}
|
||||
if g[i][selCol] == v && i != selRow {
|
||||
dup[i][selCol] = true
|
||||
}
|
||||
}
|
||||
r0 := (selRow / 3) * 3
|
||||
c0 := (selCol / 3) * 3
|
||||
for r := r0; r < r0+3; r++ {
|
||||
for c := c0; c < c0+3; c++ {
|
||||
if (r != selRow || c != selCol) && g[r][c] == v {
|
||||
dup[r][c] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return dup
|
||||
}
|
||||
|
||||
// DuplicateMapAll marks any duplicates in rows, columns, or 3x3 blocks across the entire grid.
|
||||
func DuplicateMapAll(g Grid) [9][9]bool {
|
||||
var dup [9][9]bool
|
||||
// rows
|
||||
for r := 0; r < 9; r++ {
|
||||
count := map[uint8]int{}
|
||||
for c := 0; c < 9; c++ {
|
||||
v := g[r][c]
|
||||
if v != 0 {
|
||||
count[v]++
|
||||
}
|
||||
}
|
||||
for c := 0; c < 9; c++ {
|
||||
v := g[r][c]
|
||||
if v != 0 && count[v] > 1 {
|
||||
dup[r][c] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// cols
|
||||
for c := 0; c < 9; c++ {
|
||||
count := map[uint8]int{}
|
||||
for r := 0; r < 9; r++ {
|
||||
v := g[r][c]
|
||||
if v != 0 {
|
||||
count[v]++
|
||||
}
|
||||
}
|
||||
for r := 0; r < 9; r++ {
|
||||
v := g[r][c]
|
||||
if v != 0 && count[v] > 1 {
|
||||
dup[r][c] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// blocks
|
||||
for br := 0; br < 3; br++ {
|
||||
for bc := 0; bc < 3; bc++ {
|
||||
count := map[uint8]int{}
|
||||
for r := br * 3; r < br*3+3; r++ {
|
||||
for c := bc * 3; c < bc*3+3; c++ {
|
||||
v := g[r][c]
|
||||
if v != 0 {
|
||||
count[v]++
|
||||
}
|
||||
}
|
||||
}
|
||||
for r := br * 3; r < br*3+3; r++ {
|
||||
for c := bc * 3; c < bc*3+3; c++ {
|
||||
v := g[r][c]
|
||||
if v != 0 && count[v] > 1 {
|
||||
dup[r][c] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dup
|
||||
}
|
||||
|
||||
// ConflictMap marks cells that violate Sudoku constraints (duplicates), excluding givens.
|
||||
func ConflictMap(values Grid, given [9][9]bool) [9][9]bool {
|
||||
all := DuplicateMapAll(values)
|
||||
var bad [9][9]bool
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if given[r][c] {
|
||||
continue
|
||||
}
|
||||
bad[r][c] = all[r][c]
|
||||
}
|
||||
}
|
||||
return bad
|
||||
}
|
||||
484
internal/generator/api.go
Normal file
484
internal/generator/api.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"termdoku/internal/solver"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Puzzle returns a copy of the grid suitable for rendering: 0 means blank.
|
||||
func (g Grid) Puzzle() [9][9]uint8 {
|
||||
return g
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of the grid.
|
||||
func (g Grid) Clone() Grid {
|
||||
var clone Grid
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
clone[r][c] = g[r][c]
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// IsValid checks if the current grid state is valid (no conflicts).
|
||||
func (g Grid) IsValid() bool {
|
||||
for r := range 9 {
|
||||
seen := make(map[uint8]bool)
|
||||
for c := range 9 {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
return false
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check columns
|
||||
for c := range 9 {
|
||||
seen := make(map[uint8]bool)
|
||||
for r := range 9 {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
return false
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3x3 blocks
|
||||
for br := range 3 {
|
||||
for bc := range 3 {
|
||||
seen := make(map[uint8]bool)
|
||||
for r := br * 3; r < br*3+3; r++ {
|
||||
for c := bc * 3; c < bc*3+3; c++ {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
return false
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// IsSolved checks if the grid is completely filled and valid.
|
||||
func (g Grid) IsSolved() bool {
|
||||
if !g.IsValid() {
|
||||
return false
|
||||
}
|
||||
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CountFilledCells returns the number of non-zero cells.
|
||||
func (g Grid) CountFilledCells() int {
|
||||
count := 0
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] != 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetCandidates returns possible values for a given cell.
|
||||
func (g Grid) GetCandidates(row, col int) []uint8 {
|
||||
if g[row][col] != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
used := make(map[uint8]bool)
|
||||
|
||||
for c := range 9 {
|
||||
if g[row][c] != 0 {
|
||||
used[g[row][c]] = true
|
||||
}
|
||||
}
|
||||
|
||||
for r := range 9 {
|
||||
if g[r][col] != 0 {
|
||||
used[g[r][col]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3x3 block
|
||||
r0 := (row / 3) * 3
|
||||
c0 := (col / 3) * 3
|
||||
for r := r0; r < r0+3; r++ {
|
||||
for c := c0; c < c0+3; c++ {
|
||||
if g[r][c] != 0 {
|
||||
used[g[r][c]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var candidates []uint8
|
||||
for v := uint8(1); v <= 9; v++ {
|
||||
if !used[v] {
|
||||
candidates = append(candidates, v)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
// Hint represents a hint for the player.
|
||||
type Hint struct {
|
||||
Row int
|
||||
Col int
|
||||
Value uint8
|
||||
Type HintType
|
||||
}
|
||||
|
||||
// HintType categorizes different hint strategies.
|
||||
type HintType int
|
||||
|
||||
const (
|
||||
HintNakedSingle HintType = iota // Only one candidate for a cell
|
||||
HintHiddenSingle // Only cell in row/col/box for a value
|
||||
HintRandom // Random valid cell (fallback)
|
||||
)
|
||||
|
||||
// GenerateHint provides a hint for the puzzle based on solving techniques.
|
||||
func (g Grid) GenerateHint(solution Grid) *Hint {
|
||||
// First, try to find a naked single (cell with only one candidate)
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
candidates := g.GetCandidates(r, c)
|
||||
if len(candidates) == 1 {
|
||||
return &Hint{
|
||||
Row: r,
|
||||
Col: c,
|
||||
Value: candidates[0],
|
||||
Type: HintNakedSingle,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a hidden single
|
||||
if hint := g.findHiddenSingle(); hint != nil {
|
||||
return hint
|
||||
}
|
||||
|
||||
// Fallback: return a random empty cell with its solution
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
return &Hint{
|
||||
Row: r,
|
||||
Col: c,
|
||||
Value: solution[r][c],
|
||||
Type: HintRandom,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findHiddenSingle finds a cell where a value can only go in one place in a row/col/box.
|
||||
func (g Grid) findHiddenSingle() *Hint {
|
||||
// Check rows
|
||||
for r := range 9 {
|
||||
for v := uint8(1); v <= 9; v++ {
|
||||
var possibleCols []int
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
candidates := g.GetCandidates(r, c)
|
||||
if slices.Contains(candidates, v) {
|
||||
possibleCols = append(possibleCols, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(possibleCols) == 1 {
|
||||
return &Hint{
|
||||
Row: r,
|
||||
Col: possibleCols[0],
|
||||
Value: v,
|
||||
Type: HintHiddenSingle,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check columns
|
||||
for c := range 9 {
|
||||
for v := uint8(1); v <= 9; v++ {
|
||||
var possibleRows []int
|
||||
for r := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
candidates := g.GetCandidates(r, c)
|
||||
for _, cand := range candidates {
|
||||
if cand == v {
|
||||
possibleRows = append(possibleRows, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(possibleRows) == 1 {
|
||||
return &Hint{
|
||||
Row: possibleRows[0],
|
||||
Col: c,
|
||||
Value: v,
|
||||
Type: HintHiddenSingle,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3x3 blocks
|
||||
for br := range 3 {
|
||||
for bc := range 3 {
|
||||
for v := uint8(1); v <= 9; v++ {
|
||||
type pos struct{ r, c int }
|
||||
var possiblePos []pos
|
||||
for r := br * 3; r < br*3+3; r++ {
|
||||
for c := bc * 3; c < bc*3+3; c++ {
|
||||
if g[r][c] == 0 {
|
||||
candidates := g.GetCandidates(r, c)
|
||||
for _, cand := range candidates {
|
||||
if cand == v {
|
||||
possiblePos = append(possiblePos, pos{r, c})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(possiblePos) == 1 {
|
||||
return &Hint{
|
||||
Row: possiblePos[0].r,
|
||||
Col: possiblePos[0].c,
|
||||
Value: v,
|
||||
Type: HintHiddenSingle,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PuzzleAnalysis contains metrics about puzzle difficulty and characteristics.
|
||||
type PuzzleAnalysis struct {
|
||||
FilledCells int
|
||||
EmptyCells int
|
||||
MinCandidates int
|
||||
MaxCandidates int
|
||||
AvgCandidates float64
|
||||
HasUniqueSolution bool
|
||||
EstimatedDifficulty Difficulty
|
||||
SolvingTechniques []string
|
||||
SymmetryType SymmetryType
|
||||
}
|
||||
|
||||
// SymmetryType represents the symmetry pattern of the puzzle.
|
||||
type SymmetryType int
|
||||
|
||||
const (
|
||||
SymmetryNone SymmetryType = iota
|
||||
SymmetryRotational180
|
||||
SymmetryRotational90
|
||||
SymmetryVertical
|
||||
SymmetryHorizontal
|
||||
SymmetryDiagonal
|
||||
)
|
||||
|
||||
func (s SymmetryType) String() string {
|
||||
switch s {
|
||||
case SymmetryRotational180:
|
||||
return "180° Rotational"
|
||||
case SymmetryRotational90:
|
||||
return "90° Rotational"
|
||||
case SymmetryVertical:
|
||||
return "Vertical"
|
||||
case SymmetryHorizontal:
|
||||
return "Horizontal"
|
||||
case SymmetryDiagonal:
|
||||
return "Diagonal"
|
||||
default:
|
||||
return "None"
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze performs a comprehensive analysis of the puzzle.
|
||||
func (g Grid) Analyze() PuzzleAnalysis {
|
||||
analysis := PuzzleAnalysis{
|
||||
FilledCells: g.CountFilledCells(),
|
||||
EmptyCells: 81 - g.CountFilledCells(),
|
||||
}
|
||||
|
||||
// Analyze candidates
|
||||
var totalCandidates int
|
||||
var emptyCellCount int
|
||||
analysis.MinCandidates = 9
|
||||
analysis.MaxCandidates = 0
|
||||
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
emptyCellCount++
|
||||
candidates := g.GetCandidates(r, c)
|
||||
count := len(candidates)
|
||||
totalCandidates += count
|
||||
if count < analysis.MinCandidates {
|
||||
analysis.MinCandidates = count
|
||||
}
|
||||
if count > analysis.MaxCandidates {
|
||||
analysis.MaxCandidates = count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if emptyCellCount > 0 {
|
||||
analysis.AvgCandidates = float64(totalCandidates) / float64(emptyCellCount)
|
||||
}
|
||||
|
||||
solverGrid := convertToSolverGrid(g)
|
||||
analysis.HasUniqueSolution = solver.CountSolutions(solverGrid, 100*time.Millisecond, 2) == 1
|
||||
|
||||
analysis.SolvingTechniques = g.detectSolvingTechniques()
|
||||
|
||||
analysis.EstimatedDifficulty = g.estimateDifficulty(analysis)
|
||||
|
||||
analysis.SymmetryType = g.detectSymmetry()
|
||||
|
||||
return analysis
|
||||
}
|
||||
|
||||
// detectSolvingTechniques identifies which solving techniques are needed.
|
||||
func (g Grid) detectSolvingTechniques() []string {
|
||||
var techniques []string
|
||||
|
||||
hasNakedSingle := false
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
if len(g.GetCandidates(r, c)) == 1 {
|
||||
hasNakedSingle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasNakedSingle {
|
||||
techniques = append(techniques, "Naked Singles")
|
||||
}
|
||||
|
||||
if g.findHiddenSingle() != nil {
|
||||
techniques = append(techniques, "Hidden Singles")
|
||||
}
|
||||
if len(techniques) == 0 {
|
||||
techniques = append(techniques, "Advanced Techniques Required")
|
||||
}
|
||||
|
||||
return techniques
|
||||
}
|
||||
|
||||
// estimateDifficulty estimates puzzle difficulty based on analysis.
|
||||
func (g Grid) estimateDifficulty(analysis PuzzleAnalysis) Difficulty {
|
||||
// Use multiple factors to estimate difficulty
|
||||
emptyCells := analysis.EmptyCells
|
||||
avgCandidates := analysis.AvgCandidates
|
||||
minCandidates := analysis.MinCandidates
|
||||
|
||||
// More empty cells generally means harder
|
||||
if emptyCells >= 58 {
|
||||
return Lunatic
|
||||
} else if emptyCells >= 52 {
|
||||
// Check if it requires advanced techniques
|
||||
if minCandidates <= 2 || avgCandidates < 3.5 {
|
||||
return Lunatic
|
||||
}
|
||||
return Hard
|
||||
} else if emptyCells >= 46 {
|
||||
if minCandidates <= 2 {
|
||||
return Hard
|
||||
}
|
||||
return Normal
|
||||
} else if emptyCells >= 38 {
|
||||
return Normal
|
||||
}
|
||||
return Easy
|
||||
}
|
||||
|
||||
// detectSymmetry detects the symmetry pattern of empty cells.
|
||||
func (g Grid) detectSymmetry() SymmetryType {
|
||||
if g.hasRotational180Symmetry() {
|
||||
return SymmetryRotational180
|
||||
}
|
||||
|
||||
if g.hasVerticalSymmetry() {
|
||||
return SymmetryVertical
|
||||
}
|
||||
if g.hasHorizontalSymmetry() {
|
||||
return SymmetryHorizontal
|
||||
}
|
||||
|
||||
return SymmetryNone
|
||||
}
|
||||
|
||||
func (g Grid) hasRotational180Symmetry() bool {
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
// Check if empty cells are symmetric
|
||||
if (g[r][c] == 0) != (g[8-r][8-c] == 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (g Grid) hasVerticalSymmetry() bool {
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if (g[r][c] == 0) != (g[r][8-c] == 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (g Grid) hasHorizontalSymmetry() bool {
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if (g[r][c] == 0) != (g[8-r][c] == 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
220
internal/generator/benchmark.go
Normal file
220
internal/generator/benchmark.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BenchmarkResult contains statistics from puzzle generation benchmarking.
|
||||
type BenchmarkResult struct {
|
||||
Difficulty Difficulty
|
||||
TotalAttempts int
|
||||
SuccessfulPuzzles int
|
||||
FailedPuzzles int
|
||||
AverageTime time.Duration
|
||||
MinTime time.Duration
|
||||
MaxTime time.Duration
|
||||
AverageRating float64
|
||||
Ratings []int
|
||||
}
|
||||
|
||||
// BenchmarkGeneration tests puzzle generation performance for a given difficulty.
|
||||
func BenchmarkGeneration(d Difficulty, attempts int) BenchmarkResult {
|
||||
result := BenchmarkResult{
|
||||
Difficulty: d,
|
||||
TotalAttempts: attempts,
|
||||
MinTime: time.Hour, // Start with a large value
|
||||
Ratings: make([]int, 0, attempts),
|
||||
}
|
||||
|
||||
var totalDuration time.Duration
|
||||
|
||||
for i := 0; i < attempts; i++ {
|
||||
seed := fmt.Sprintf("benchmark-%d-%d", time.Now().UnixNano(), i)
|
||||
start := time.Now()
|
||||
|
||||
puzzle, err := Generate(d, seed)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
result.FailedPuzzles++
|
||||
continue
|
||||
}
|
||||
|
||||
result.SuccessfulPuzzles++
|
||||
totalDuration += elapsed
|
||||
|
||||
if elapsed < result.MinTime {
|
||||
result.MinTime = elapsed
|
||||
}
|
||||
if elapsed > result.MaxTime {
|
||||
result.MaxTime = elapsed
|
||||
}
|
||||
|
||||
rating := RatePuzzle(puzzle)
|
||||
result.Ratings = append(result.Ratings, rating)
|
||||
}
|
||||
|
||||
if result.SuccessfulPuzzles > 0 {
|
||||
result.AverageTime = totalDuration / time.Duration(result.SuccessfulPuzzles)
|
||||
|
||||
var totalRating int
|
||||
for _, r := range result.Ratings {
|
||||
totalRating += r
|
||||
}
|
||||
result.AverageRating = float64(totalRating) / float64(len(result.Ratings))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// String returns a formatted string representation of the benchmark result.
|
||||
func (br BenchmarkResult) String() string {
|
||||
successRate := float64(br.SuccessfulPuzzles) / float64(br.TotalAttempts) * 100
|
||||
|
||||
return fmt.Sprintf(`Benchmark Results for %s:
|
||||
Total Attempts: %d
|
||||
Successful: %d (%.1f%%)
|
||||
Failed: %d
|
||||
Average Time: %v
|
||||
Min Time: %v
|
||||
Max Time: %v
|
||||
Average Rating: %.1f/100
|
||||
`,
|
||||
br.Difficulty.String(),
|
||||
br.TotalAttempts,
|
||||
br.SuccessfulPuzzles,
|
||||
successRate,
|
||||
br.FailedPuzzles,
|
||||
br.AverageTime,
|
||||
br.MinTime,
|
||||
br.MaxTime,
|
||||
br.AverageRating,
|
||||
)
|
||||
}
|
||||
|
||||
// CompareGenerationMethods compares standard vs symmetric generation.
|
||||
func CompareGenerationMethods(d Difficulty, attempts int) (standard, symmetric BenchmarkResult) {
|
||||
// Benchmark standard generation
|
||||
standard = BenchmarkGeneration(d, attempts)
|
||||
|
||||
// Benchmark symmetric generation
|
||||
symmetric = BenchmarkResult{
|
||||
Difficulty: d,
|
||||
TotalAttempts: attempts,
|
||||
MinTime: time.Hour,
|
||||
Ratings: make([]int, 0, attempts),
|
||||
}
|
||||
|
||||
var totalDuration time.Duration
|
||||
|
||||
for i := 0; i < attempts; i++ {
|
||||
seed := fmt.Sprintf("symmetric-benchmark-%d-%d", time.Now().UnixNano(), i)
|
||||
start := time.Now()
|
||||
|
||||
puzzle, err := GenerateWithSymmetry(d, seed, SymmetryRotational180)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
symmetric.FailedPuzzles++
|
||||
continue
|
||||
}
|
||||
|
||||
symmetric.SuccessfulPuzzles++
|
||||
totalDuration += elapsed
|
||||
|
||||
if elapsed < symmetric.MinTime {
|
||||
symmetric.MinTime = elapsed
|
||||
}
|
||||
if elapsed > symmetric.MaxTime {
|
||||
symmetric.MaxTime = elapsed
|
||||
}
|
||||
|
||||
rating := RatePuzzle(puzzle)
|
||||
symmetric.Ratings = append(symmetric.Ratings, rating)
|
||||
}
|
||||
|
||||
if symmetric.SuccessfulPuzzles > 0 {
|
||||
symmetric.AverageTime = totalDuration / time.Duration(symmetric.SuccessfulPuzzles)
|
||||
|
||||
var totalRating int
|
||||
for _, r := range symmetric.Ratings {
|
||||
totalRating += r
|
||||
}
|
||||
symmetric.AverageRating = float64(totalRating) / float64(len(symmetric.Ratings))
|
||||
}
|
||||
|
||||
return standard, symmetric
|
||||
}
|
||||
|
||||
// PuzzleStatistics provides detailed statistics about a generated puzzle.
|
||||
type PuzzleStatistics struct {
|
||||
Grid Grid
|
||||
Analysis PuzzleAnalysis
|
||||
Rating int
|
||||
EstimatedSolveTime time.Duration
|
||||
ComplexityScore float64
|
||||
}
|
||||
|
||||
// GetPuzzleStatistics performs comprehensive analysis on a puzzle.
|
||||
func GetPuzzleStatistics(g Grid) PuzzleStatistics {
|
||||
analysis := g.Analyze()
|
||||
rating := RatePuzzle(g)
|
||||
|
||||
// Estimate solve time based on difficulty (rough approximation)
|
||||
var estimatedTime time.Duration
|
||||
switch analysis.EstimatedDifficulty {
|
||||
case Easy:
|
||||
estimatedTime = 3 * time.Minute
|
||||
case Normal:
|
||||
estimatedTime = 8 * time.Minute
|
||||
case Hard:
|
||||
estimatedTime = 15 * time.Minute
|
||||
case Expert:
|
||||
estimatedTime = 25 * time.Minute
|
||||
case Lunatic:
|
||||
estimatedTime = 45 * time.Minute
|
||||
}
|
||||
|
||||
// Calculate complexity score (0-1)
|
||||
complexityScore := float64(rating) / 100.0
|
||||
|
||||
return PuzzleStatistics{
|
||||
Grid: g,
|
||||
Analysis: analysis,
|
||||
Rating: rating,
|
||||
EstimatedSolveTime: estimatedTime,
|
||||
ComplexityScore: complexityScore,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns a formatted string of puzzle statistics.
|
||||
func (ps PuzzleStatistics) String() string {
|
||||
return fmt.Sprintf(`Puzzle Statistics:
|
||||
Difficulty: %s
|
||||
Rating: %d/100
|
||||
Filled Cells: %d
|
||||
Empty Cells: %d
|
||||
Min Candidates: %d
|
||||
Max Candidates: %d
|
||||
Avg Candidates: %.2f
|
||||
Unique Solution: %v
|
||||
Symmetry: %s
|
||||
Techniques: %v
|
||||
Complexity Score: %.2f
|
||||
Est. Solve Time: %v
|
||||
`,
|
||||
ps.Analysis.EstimatedDifficulty.String(),
|
||||
ps.Rating,
|
||||
ps.Analysis.FilledCells,
|
||||
ps.Analysis.EmptyCells,
|
||||
ps.Analysis.MinCandidates,
|
||||
ps.Analysis.MaxCandidates,
|
||||
ps.Analysis.AvgCandidates,
|
||||
ps.Analysis.HasUniqueSolution,
|
||||
ps.Analysis.SymmetryType.String(),
|
||||
ps.Analysis.SolvingTechniques,
|
||||
ps.ComplexityScore,
|
||||
ps.EstimatedSolveTime,
|
||||
)
|
||||
}
|
||||
275
internal/generator/core.go
Normal file
275
internal/generator/core.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"termdoku/internal/solver"
|
||||
)
|
||||
|
||||
// randomizedFullSolution builds a complete valid Sudoku solution using randomized DFS.
|
||||
func randomizedFullSolution(seed string, timeout time.Duration) (Grid, error) {
|
||||
var rng *rand.Rand
|
||||
if seed == "" {
|
||||
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
} else {
|
||||
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed))))
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
var g Grid
|
||||
if fillCellRandom(&g, 0, 0, rng, deadline) {
|
||||
return g, nil
|
||||
}
|
||||
return Grid{}, ErrTimeout
|
||||
}
|
||||
|
||||
func fillCellRandom(g *Grid, row, col int, rng *rand.Rand, deadline time.Time) bool {
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
nextRow, nextCol := row, col+1
|
||||
if nextCol == 9 {
|
||||
nextRow++
|
||||
nextCol = 0
|
||||
}
|
||||
if row == 9 {
|
||||
return true
|
||||
}
|
||||
vals := []uint8{1, 2, 3, 4, 5, 6, 7, 8, 9}
|
||||
rng.Shuffle(len(vals), func(i, j int) { vals[i], vals[j] = vals[j], vals[i] })
|
||||
for _, v := range vals {
|
||||
if isSafe(*g, row, col, v) {
|
||||
g[row][col] = v
|
||||
if fillCellRandom(g, nextRow, nextCol, rng, deadline) {
|
||||
return true
|
||||
}
|
||||
g[row][col] = 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSafe(g Grid, row, col int, v uint8) bool {
|
||||
for i := 0; i < 9; i++ {
|
||||
if g[row][i] == v || g[i][col] == v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
r0 := (row / 3) * 3
|
||||
c0 := (col / 3) * 3
|
||||
for r := r0; r < r0+3; r++ {
|
||||
for c := c0; c < c0+3; c++ {
|
||||
if g[r][c] == v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// carveCellsUnique removes cells while trying to keep a single solution.
|
||||
func carveCellsUnique(full Grid, targetRemoved int, seed string, timeout time.Duration) (Grid, error) {
|
||||
puzzle := full
|
||||
var rng *rand.Rand
|
||||
if seed == "" {
|
||||
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
} else {
|
||||
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed) + 0x9e3779b97f4a7c15)))
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
cells := make([]int, 81)
|
||||
for i := 0; i < 81; i++ {
|
||||
cells[i] = i
|
||||
}
|
||||
rng.Shuffle(len(cells), func(i, j int) { cells[i], cells[j] = cells[j], cells[i] })
|
||||
removed := 0
|
||||
for _, idx := range cells {
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
r := idx / 9
|
||||
c := idx % 9
|
||||
backup := puzzle[r][c]
|
||||
puzzle[r][c] = 0
|
||||
// Check uniqueness using solver.CountSolutions up to 2
|
||||
if solver.CountSolutions(convertToSolverGrid(puzzle), 50*time.Millisecond, 2) != 1 {
|
||||
puzzle[r][c] = backup
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
if removed >= targetRemoved {
|
||||
break
|
||||
}
|
||||
}
|
||||
return puzzle, nil
|
||||
}
|
||||
|
||||
// carveCellsSymmetric removes cells with symmetry pattern while maintaining uniqueness.
|
||||
func carveCellsSymmetric(full Grid, targetRemoved int, seed string, timeout time.Duration, symmetry SymmetryType) (Grid, error) {
|
||||
puzzle := full
|
||||
var rng *rand.Rand
|
||||
if seed == "" {
|
||||
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
} else {
|
||||
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed) + 0x9e3779b97f4a7c15)))
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
// Generate cell pairs based on symmetry type
|
||||
var cellPairs [][]struct{ r, c int }
|
||||
switch symmetry {
|
||||
case SymmetryRotational180:
|
||||
cellPairs = generateRotational180Pairs()
|
||||
case SymmetryVertical:
|
||||
cellPairs = generateVerticalPairs()
|
||||
case SymmetryHorizontal:
|
||||
cellPairs = generateHorizontalPairs()
|
||||
default:
|
||||
// Fallback to non-symmetric
|
||||
return carveCellsUnique(full, targetRemoved, seed, timeout)
|
||||
}
|
||||
|
||||
rng.Shuffle(len(cellPairs), func(i, j int) { cellPairs[i], cellPairs[j] = cellPairs[j], cellPairs[i] })
|
||||
|
||||
removed := 0
|
||||
for _, pair := range cellPairs {
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
|
||||
// Try removing all cells in the pair
|
||||
backups := make([]uint8, len(pair))
|
||||
for i, pos := range pair {
|
||||
backups[i] = puzzle[pos.r][pos.c]
|
||||
puzzle[pos.r][pos.c] = 0
|
||||
}
|
||||
|
||||
// Check if still unique
|
||||
if solver.CountSolutions(convertToSolverGrid(puzzle), 50*time.Millisecond, 2) != 1 {
|
||||
// Restore if not unique
|
||||
for i, pos := range pair {
|
||||
puzzle[pos.r][pos.c] = backups[i]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
removed += len(pair)
|
||||
if removed >= targetRemoved {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return puzzle, nil
|
||||
}
|
||||
|
||||
// generateRotational180Pairs creates cell pairs with 180° rotational symmetry.
|
||||
func generateRotational180Pairs() [][]struct{ r, c int } {
|
||||
var pairs [][]struct{ r, c int }
|
||||
used := make(map[int]bool)
|
||||
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
idx := r*9 + c
|
||||
if used[idx] {
|
||||
continue
|
||||
}
|
||||
|
||||
r2 := 8 - r
|
||||
c2 := 8 - c
|
||||
idx2 := r2*9 + c2
|
||||
|
||||
if idx == idx2 {
|
||||
// Center cell (4,4)
|
||||
pairs = append(pairs, []struct{ r, c int }{{r, c}})
|
||||
} else {
|
||||
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r2, c2}})
|
||||
used[idx2] = true
|
||||
}
|
||||
used[idx] = true
|
||||
}
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
// generateVerticalPairs creates cell pairs with vertical symmetry.
|
||||
func generateVerticalPairs() [][]struct{ r, c int } {
|
||||
var pairs [][]struct{ r, c int }
|
||||
used := make(map[int]bool)
|
||||
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
idx := r*9 + c
|
||||
if used[idx] {
|
||||
continue
|
||||
}
|
||||
|
||||
c2 := 8 - c
|
||||
idx2 := r*9 + c2
|
||||
|
||||
if c == c2 {
|
||||
// Center column
|
||||
pairs = append(pairs, []struct{ r, c int }{{r, c}})
|
||||
} else {
|
||||
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r, c2}})
|
||||
used[idx2] = true
|
||||
}
|
||||
used[idx] = true
|
||||
}
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
// generateHorizontalPairs creates cell pairs with horizontal symmetry.
|
||||
func generateHorizontalPairs() [][]struct{ r, c int } {
|
||||
var pairs [][]struct{ r, c int }
|
||||
used := make(map[int]bool)
|
||||
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
idx := r*9 + c
|
||||
if used[idx] {
|
||||
continue
|
||||
}
|
||||
|
||||
r2 := 8 - r
|
||||
idx2 := r2*9 + c
|
||||
|
||||
if r == r2 {
|
||||
// Center row
|
||||
pairs = append(pairs, []struct{ r, c int }{{r, c}})
|
||||
} else {
|
||||
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r2, c}})
|
||||
used[idx2] = true
|
||||
}
|
||||
used[idx] = true
|
||||
}
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
func convertToSolverGrid(g Grid) solver.Grid {
|
||||
var s solver.Grid
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
s[r][c] = g[r][c]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Simple FNV-1a 64-bit hash for seed strings.
|
||||
func hashStringToUint64(s string) uint64 {
|
||||
const (
|
||||
offset64 = 1469598103934665603
|
||||
prime64 = 1099511628211
|
||||
)
|
||||
h := uint64(offset64)
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint64(s[i])
|
||||
h *= prime64
|
||||
}
|
||||
return h
|
||||
}
|
||||
248
internal/generator/generator.go
Normal file
248
internal/generator/generator.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"termdoku/internal/solver"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Difficulty represents puzzle difficulty tiers.
|
||||
type Difficulty int
|
||||
|
||||
const (
|
||||
Easy Difficulty = iota
|
||||
Normal
|
||||
Hard
|
||||
Expert
|
||||
Lunatic
|
||||
)
|
||||
|
||||
// String returns the string representation of the difficulty.
|
||||
func (d Difficulty) String() string {
|
||||
switch d {
|
||||
case Easy:
|
||||
return "Easy"
|
||||
case Normal:
|
||||
return "Normal"
|
||||
case Hard:
|
||||
return "Hard"
|
||||
case Expert:
|
||||
return "Expert"
|
||||
case Lunatic:
|
||||
return "Lunatic"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DailySeed returns a stable seed based on UTC date (YYYY-MM-DD).
|
||||
func DailySeed(t time.Time) string {
|
||||
utc := t.UTC()
|
||||
return utc.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Params controls generation knobs derived from difficulty.
|
||||
type Params struct {
|
||||
// number of blanks/removed cells; higher -> harder
|
||||
RemovedCells int
|
||||
// backtracking timeout to avoid worst-cases
|
||||
Timeout time.Duration
|
||||
// symmetry pattern to use (optional)
|
||||
Symmetry SymmetryType
|
||||
// whether to enforce symmetry
|
||||
UseSymmetry bool
|
||||
}
|
||||
|
||||
// paramsFor maps Difficulty to generation parameters.
|
||||
func paramsFor(d Difficulty) Params {
|
||||
switch d {
|
||||
case Easy:
|
||||
return Params{
|
||||
RemovedCells: 38,
|
||||
Timeout: 150 * time.Millisecond,
|
||||
UseSymmetry: false,
|
||||
}
|
||||
case Normal:
|
||||
return Params{
|
||||
RemovedCells: 46,
|
||||
Timeout: 150 * time.Millisecond,
|
||||
UseSymmetry: false,
|
||||
}
|
||||
case Hard:
|
||||
return Params{
|
||||
RemovedCells: 52,
|
||||
Timeout: 200 * time.Millisecond,
|
||||
UseSymmetry: false,
|
||||
}
|
||||
case Expert:
|
||||
return Params{
|
||||
RemovedCells: 56,
|
||||
Timeout: 250 * time.Millisecond,
|
||||
UseSymmetry: false,
|
||||
}
|
||||
case Lunatic:
|
||||
return Params{
|
||||
RemovedCells: 60,
|
||||
Timeout: 300 * time.Millisecond,
|
||||
UseSymmetry: false,
|
||||
}
|
||||
default:
|
||||
return Params{
|
||||
RemovedCells: 46,
|
||||
Timeout: 150 * time.Millisecond,
|
||||
UseSymmetry: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grid is a 9x9 Sudoku grid. 0 represents empty.
|
||||
type Grid [9][9]uint8
|
||||
|
||||
// ErrTimeout is returned when generation exceeds the configured timeout.
|
||||
var ErrTimeout = errors.New("generation timed out")
|
||||
|
||||
// Generate creates a Sudoku puzzle with the given difficulty and seed.
|
||||
// - If seed is empty, uses current time for randomness.
|
||||
// - For Daily mode, pass seed from DailySeed(date).
|
||||
// Returns a puzzle grid with 0 as blanks, aimed at single-solution.
|
||||
func Generate(d Difficulty, seed string) (Grid, error) {
|
||||
p := paramsFor(d)
|
||||
return generateWithParams(p, seed)
|
||||
}
|
||||
|
||||
// GenerateDaily creates a daily puzzle based on UTC date.
|
||||
func GenerateDaily(date time.Time) (Grid, error) {
|
||||
return Generate(Normal, DailySeed(date))
|
||||
}
|
||||
|
||||
// GenerateWithSymmetry creates a puzzle with a specific symmetry pattern.
|
||||
func GenerateWithSymmetry(d Difficulty, seed string, symmetry SymmetryType) (Grid, error) {
|
||||
p := paramsFor(d)
|
||||
p.UseSymmetry = true
|
||||
p.Symmetry = symmetry
|
||||
return generateWithParams(p, seed)
|
||||
}
|
||||
|
||||
// GenerateCustom creates a puzzle with custom parameters.
|
||||
func GenerateCustom(removedCells int, seed string, useSymmetry bool, symmetry SymmetryType) (Grid, error) {
|
||||
p := Params{
|
||||
RemovedCells: removedCells,
|
||||
Timeout: 300 * time.Millisecond,
|
||||
UseSymmetry: useSymmetry,
|
||||
Symmetry: symmetry,
|
||||
}
|
||||
return generateWithParams(p, seed)
|
||||
}
|
||||
|
||||
// PuzzleWithSolution represents a puzzle along with its solution.
|
||||
type PuzzleWithSolution struct {
|
||||
Puzzle Grid
|
||||
Solution Grid
|
||||
Analysis PuzzleAnalysis
|
||||
}
|
||||
|
||||
// GenerateWithAnalysis creates a puzzle and returns it with its solution and analysis.
|
||||
func GenerateWithAnalysis(d Difficulty, seed string) (PuzzleWithSolution, error) {
|
||||
puzzle, err := Generate(d, seed)
|
||||
if err != nil {
|
||||
return PuzzleWithSolution{}, err
|
||||
}
|
||||
|
||||
// Solve to get the solution
|
||||
solution := puzzle.Clone()
|
||||
solverGrid := convertToSolverGrid(solution)
|
||||
if !solver.Solve(&solverGrid, 500*time.Millisecond) {
|
||||
return PuzzleWithSolution{}, errors.New("failed to solve generated puzzle")
|
||||
}
|
||||
|
||||
// Convert back to Grid
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
solution[r][c] = solverGrid[r][c]
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze the puzzle
|
||||
analysis := puzzle.Analyze()
|
||||
|
||||
return PuzzleWithSolution{
|
||||
Puzzle: puzzle,
|
||||
Solution: solution,
|
||||
Analysis: analysis,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RatePuzzle provides a difficulty rating from 0-100 based on puzzle characteristics.
|
||||
func RatePuzzle(g Grid) int {
|
||||
analysis := g.Analyze()
|
||||
|
||||
// Base score from empty cells (0-40 points)
|
||||
emptyScore := (analysis.EmptyCells * 40) / 81
|
||||
|
||||
// Candidate complexity (0-30 points)
|
||||
candidateScore := 0
|
||||
if analysis.AvgCandidates > 0 {
|
||||
// Lower average candidates = harder
|
||||
candidateScore = int((9.0 - analysis.AvgCandidates) * 3.3)
|
||||
if candidateScore < 0 {
|
||||
candidateScore = 0
|
||||
}
|
||||
if candidateScore > 30 {
|
||||
candidateScore = 30
|
||||
}
|
||||
}
|
||||
|
||||
// Technique complexity (0-30 points)
|
||||
techniqueScore := 0
|
||||
for _, tech := range analysis.SolvingTechniques {
|
||||
if tech == "Advanced Techniques Required" {
|
||||
techniqueScore = 30
|
||||
break
|
||||
} else if tech == "Hidden Singles" {
|
||||
techniqueScore = 15
|
||||
} else if tech == "Naked Singles" {
|
||||
techniqueScore = 5
|
||||
}
|
||||
}
|
||||
|
||||
totalScore := emptyScore + candidateScore + techniqueScore
|
||||
if totalScore > 100 {
|
||||
totalScore = 100
|
||||
}
|
||||
|
||||
return totalScore
|
||||
}
|
||||
|
||||
// DifficultyFromRating converts a rating (0-100) to a Difficulty level.
|
||||
func DifficultyFromRating(rating int) Difficulty {
|
||||
if rating >= 85 {
|
||||
return Lunatic
|
||||
} else if rating >= 70 {
|
||||
return Expert
|
||||
} else if rating >= 50 {
|
||||
return Hard
|
||||
} else if rating >= 30 {
|
||||
return Normal
|
||||
}
|
||||
return Easy
|
||||
}
|
||||
|
||||
// generateWithParams contains the core generation pipeline.
|
||||
func generateWithParams(p Params, seed string) (Grid, error) {
|
||||
// 1) Create a full valid solution via randomized backtracking
|
||||
full, err := randomizedFullSolution(seed, p.Timeout)
|
||||
if err != nil {
|
||||
return Grid{}, err
|
||||
}
|
||||
// 2) Remove cells according to difficulty while keeping uniqueness if possible
|
||||
var puzzle Grid
|
||||
if p.UseSymmetry {
|
||||
puzzle, err = carveCellsSymmetric(full, p.RemovedCells, seed, p.Timeout, p.Symmetry)
|
||||
} else {
|
||||
puzzle, err = carveCellsUnique(full, p.RemovedCells, seed, p.Timeout)
|
||||
}
|
||||
if err != nil {
|
||||
return Grid{}, err
|
||||
}
|
||||
return puzzle, nil
|
||||
}
|
||||
298
internal/generator/utils.go
Normal file
298
internal/generator/utils.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"termdoku/internal/solver"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PrintGrid prints the grid in a human-readable format.
|
||||
func PrintGrid(g Grid) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("┌───────┬───────┬───────┐\n")
|
||||
for r := range 9 {
|
||||
sb.WriteString("│ ")
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
sb.WriteString(".")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%d", g[r][c]))
|
||||
}
|
||||
|
||||
if c%3 == 2 {
|
||||
sb.WriteString(" │ ")
|
||||
} else {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if r%3 == 2 && r != 8 {
|
||||
sb.WriteString("├───────┼───────┼───────┤\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("└───────┴───────┴───────┘\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GridToString converts a grid to a compact string representation.
|
||||
func GridToString(g Grid) string {
|
||||
var sb strings.Builder
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
sb.WriteString(".")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%d", g[r][c]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// StringToGrid converts a string representation back to a grid.
|
||||
// The string should be 81 characters long, with '0' or '.' for empty cells.
|
||||
func StringToGrid(s string) (Grid, error) {
|
||||
if len(s) != 81 {
|
||||
return Grid{}, fmt.Errorf("invalid string length: expected 81, got %d", len(s))
|
||||
}
|
||||
|
||||
var g Grid
|
||||
for i, ch := range s {
|
||||
r := i / 9
|
||||
c := i % 9
|
||||
|
||||
if ch == '.' || ch == '0' {
|
||||
g[r][c] = 0
|
||||
} else if ch >= '1' && ch <= '9' {
|
||||
g[r][c] = uint8(ch - '0')
|
||||
} else {
|
||||
return Grid{}, fmt.Errorf("invalid character at position %d: %c", i, ch)
|
||||
}
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// CompareGrids returns the differences between two grids.
|
||||
func CompareGrids(g1, g2 Grid) []struct {
|
||||
Row, Col int
|
||||
Val1, Val2 uint8
|
||||
} {
|
||||
var diffs []struct {
|
||||
Row, Col int
|
||||
Val1, Val2 uint8
|
||||
}
|
||||
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if g1[r][c] != g2[r][c] {
|
||||
diffs = append(diffs, struct {
|
||||
Row, Col int
|
||||
Val1, Val2 uint8
|
||||
}{
|
||||
Row: r,
|
||||
Col: c,
|
||||
Val1: g1[r][c],
|
||||
Val2: g2[r][c],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs
|
||||
}
|
||||
|
||||
// GetEmptyCells returns a list of all empty cell positions.
|
||||
func GetEmptyCells(g Grid) []struct{ Row, Col int } {
|
||||
var empty []struct{ Row, Col int }
|
||||
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if g[r][c] == 0 {
|
||||
empty = append(empty, struct{ Row, Col int }{r, c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return empty
|
||||
}
|
||||
|
||||
// GetFilledCells returns a list of all filled cell positions with their values.
|
||||
func GetFilledCells(g Grid) []struct {
|
||||
Row, Col int
|
||||
Value uint8
|
||||
} {
|
||||
var filled []struct {
|
||||
Row, Col int
|
||||
Value uint8
|
||||
}
|
||||
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if g[r][c] != 0 {
|
||||
filled = append(filled, struct {
|
||||
Row, Col int
|
||||
Value uint8
|
||||
}{r, c, g[r][c]})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filled
|
||||
}
|
||||
|
||||
// GetRegionCells returns all cell positions in a 3x3 region.
|
||||
func GetRegionCells(blockRow, blockCol int) []struct{ Row, Col int } {
|
||||
if blockRow < 0 || blockRow > 2 || blockCol < 0 || blockCol > 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cells []struct{ Row, Col int }
|
||||
startRow := blockRow * 3
|
||||
startCol := blockCol * 3
|
||||
|
||||
for r := startRow; r < startRow+3; r++ {
|
||||
for c := startCol; c < startCol+3; c++ {
|
||||
cells = append(cells, struct{ Row, Col int }{r, c})
|
||||
}
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
// ValidateGridStructure checks if a grid has valid structure (no duplicates).
|
||||
func ValidateGridStructure(g Grid) []string {
|
||||
var errors []string
|
||||
|
||||
// Check rows
|
||||
for r := range 9 {
|
||||
seen := make(map[uint8]bool)
|
||||
for c := 0; c < 9; c++ {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
errors = append(errors, fmt.Sprintf("Duplicate %d in row %d", v, r+1))
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check columns
|
||||
for c := range 9 {
|
||||
seen := make(map[uint8]bool)
|
||||
for r := range 9 {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
errors = append(errors, fmt.Sprintf("Duplicate %d in column %d", v, c+1))
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3x3 blocks
|
||||
for br := range 3 {
|
||||
for bc := range 3 {
|
||||
seen := make(map[uint8]bool)
|
||||
for r := br * 3; r < br*3+3; r++ {
|
||||
for c := bc * 3; c < bc*3+3; c++ {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
errors = append(errors, fmt.Sprintf("Duplicate %d in block (%d,%d)", v, br+1, bc+1))
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// GetCandidateMap returns a map of all candidates for all empty cells.
|
||||
func GetCandidateMap(g Grid) map[struct{ Row, Col int }][]uint8 {
|
||||
candidateMap := make(map[struct{ Row, Col int }][]uint8)
|
||||
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
candidates := g.GetCandidates(r, c)
|
||||
if len(candidates) > 0 {
|
||||
candidateMap[struct{ Row, Col int }{r, c}] = candidates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateMap
|
||||
}
|
||||
|
||||
// CalculateCompletionPercentage returns the percentage of filled cells.
|
||||
func CalculateCompletionPercentage(g Grid) float64 {
|
||||
filled := g.CountFilledCells()
|
||||
return (float64(filled) / 81.0) * 100.0
|
||||
}
|
||||
|
||||
// GetMostConstrainedCell returns the empty cell with the fewest candidates.
|
||||
// This is useful for implementing solving strategies.
|
||||
func GetMostConstrainedCell(g Grid) (row, col int, candidates []uint8, found bool) {
|
||||
minCandidates := 10
|
||||
found = false
|
||||
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] == 0 {
|
||||
cands := g.GetCandidates(r, c)
|
||||
if len(cands) < minCandidates {
|
||||
minCandidates = len(cands)
|
||||
row = r
|
||||
col = c
|
||||
candidates = cands
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// IsMinimalPuzzle checks if removing any filled cell would result in multiple solutions.
|
||||
// This is computationally expensive and should be used sparingly.
|
||||
func IsMinimalPuzzle(g Grid) bool {
|
||||
// For each filled cell, try removing it and check if puzzle still has unique solution
|
||||
for r := range 9 {
|
||||
for c := range 9 {
|
||||
if g[r][c] != 0 {
|
||||
// Try removing this cell
|
||||
backup := g[r][c]
|
||||
g[r][c] = 0
|
||||
|
||||
// Check if still unique
|
||||
solverGrid := convertToSolverGrid(g)
|
||||
solutionCount := solver.CountSolutions(solverGrid, 100*time.Millisecond, 2)
|
||||
|
||||
// Restore the cell
|
||||
g[r][c] = backup
|
||||
|
||||
// If removing this cell doesn't maintain uniqueness, it's not minimal
|
||||
if solutionCount != 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
91
internal/savegame/persistence.go
Normal file
91
internal/savegame/persistence.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package savegame
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"termdoku/internal/game"
|
||||
)
|
||||
|
||||
// SavedGame represents a saved game state
|
||||
type SavedGame struct {
|
||||
Board game.Grid `json:"board"`
|
||||
Solution game.Grid `json:"solution"`
|
||||
Given [9][9]bool `json:"given"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Elapsed int64 `json:"elapsed"` // seconds
|
||||
StartTime time.Time `json:"startTime"`
|
||||
HintsUsed int `json:"hintsUsed"`
|
||||
Notes map[string][]uint8 `json:"notes"` // JSON keys must be strings
|
||||
SavedAt time.Time `json:"savedAt"`
|
||||
AutoCheck bool `json:"autoCheck"`
|
||||
TimerEnabled bool `json:"timerEnabled"`
|
||||
}
|
||||
|
||||
// path returns the save file path
|
||||
func path() (string, error) {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(h, ".termdoku", "savegame.json"), nil
|
||||
}
|
||||
|
||||
// Save writes a game state to disk
|
||||
func Save(sg SavedGame) error {
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(sg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
|
||||
// Load reads a game state from disk
|
||||
func Load() (SavedGame, error) {
|
||||
var sg SavedGame
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return sg, err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return sg, errors.New("no saved game found")
|
||||
}
|
||||
return sg, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &sg); err != nil {
|
||||
return sg, err
|
||||
}
|
||||
return sg, nil
|
||||
}
|
||||
|
||||
// Exists checks if a saved game exists
|
||||
func Exists() bool {
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Delete removes the saved game
|
||||
func Delete() error {
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(p)
|
||||
}
|
||||
288
internal/solver/solver.go
Normal file
288
internal/solver/solver.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package solver
|
||||
|
||||
import "time"
|
||||
|
||||
// Grid matches generator's grid representation
|
||||
type Grid [9][9]uint8
|
||||
|
||||
// Solve attempts to fill the grid in-place using backtracking.
|
||||
// Returns whether a solution was found before timeout.
|
||||
func Solve(g *Grid, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
return solveBacktrack(g, deadline)
|
||||
}
|
||||
|
||||
// CountSolutions counts up to maxCount solutions for uniqueness check.
|
||||
func CountSolutions(g Grid, timeout time.Duration, maxCount int) int {
|
||||
deadline := time.Now().Add(timeout)
|
||||
count := 0
|
||||
var dfs func(*Grid) bool
|
||||
dfs = func(cur *Grid) bool {
|
||||
if time.Now().After(deadline) {
|
||||
return true
|
||||
}
|
||||
// Use most-constrained heuristic for faster counting
|
||||
row, col, ok := findMostConstrained(*cur)
|
||||
if !ok {
|
||||
count++
|
||||
return count >= maxCount
|
||||
}
|
||||
cands := candidates(*cur, row, col)
|
||||
if len(cands) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range cands {
|
||||
cur[row][col] = v
|
||||
if dfs(cur) {
|
||||
return true
|
||||
}
|
||||
cur[row][col] = 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
copyGrid := g
|
||||
dfs(©Grid)
|
||||
return count
|
||||
}
|
||||
|
||||
func solveBacktrack(g *Grid, deadline time.Time) bool {
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
// Use most-constrained-first heuristic for better performance
|
||||
row, col, ok := findMostConstrained(*g)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
cands := candidates(*g, row, col)
|
||||
// If no candidates available, this path is invalid
|
||||
if len(cands) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range cands {
|
||||
g[row][col] = v
|
||||
if solveBacktrack(g, deadline) {
|
||||
return true
|
||||
}
|
||||
g[row][col] = 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findEmpty(g Grid) (int, int, bool) {
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if g[r][c] == 0 {
|
||||
return r, c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// findMostConstrained finds the empty cell with the fewest candidates.
|
||||
// This heuristic significantly improves solving performance.
|
||||
func findMostConstrained(g Grid) (int, int, bool) {
|
||||
minCands := 10
|
||||
bestRow, bestCol := -1, -1
|
||||
found := false
|
||||
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if g[r][c] == 0 {
|
||||
cands := candidates(g, r, c)
|
||||
if len(cands) < minCands {
|
||||
minCands = len(cands)
|
||||
bestRow = r
|
||||
bestCol = c
|
||||
found = true
|
||||
// If we find a cell with only one candidate, use it immediately
|
||||
if minCands == 1 {
|
||||
return bestRow, bestCol, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestRow, bestCol, found
|
||||
}
|
||||
|
||||
func candidates(g Grid, row, col int) []uint8 {
|
||||
used := [10]bool{}
|
||||
for i := 0; i < 9; i++ {
|
||||
used[g[row][i]] = true
|
||||
used[g[i][col]] = true
|
||||
}
|
||||
r0 := (row / 3) * 3
|
||||
c0 := (col / 3) * 3
|
||||
for r := r0; r < r0+3; r++ {
|
||||
for c := c0; c < c0+3; c++ {
|
||||
used[g[r][c]] = true
|
||||
}
|
||||
}
|
||||
var out []uint8
|
||||
for v := 1; v <= 9; v++ {
|
||||
if !used[v] {
|
||||
out = append(out, uint8(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsValid checks if the grid is in a valid state (no conflicts).
|
||||
func IsValid(g Grid) bool {
|
||||
// Check rows
|
||||
for r := 0; r < 9; r++ {
|
||||
seen := [10]bool{}
|
||||
for c := 0; c < 9; c++ {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
return false
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check columns
|
||||
for c := 0; c < 9; c++ {
|
||||
seen := [10]bool{}
|
||||
for r := 0; r < 9; r++ {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
return false
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3x3 blocks
|
||||
for br := 0; br < 3; br++ {
|
||||
for bc := 0; bc < 3; bc++ {
|
||||
seen := [10]bool{}
|
||||
for r := br * 3; r < br*3+3; r++ {
|
||||
for c := bc * 3; c < bc*3+3; c++ {
|
||||
v := g[r][c]
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
return false
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// IsSolved checks if the grid is completely filled and valid.
|
||||
func IsSolved(g Grid) bool {
|
||||
if !IsValid(g) {
|
||||
return false
|
||||
}
|
||||
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if g[r][c] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SolveStep represents a single step in the solving process.
|
||||
type SolveStep struct {
|
||||
Row int
|
||||
Col int
|
||||
Value uint8
|
||||
Candidates []uint8
|
||||
StepNumber int
|
||||
}
|
||||
|
||||
// SolveWithSteps solves the puzzle and returns all steps taken.
|
||||
func SolveWithSteps(g Grid, timeout time.Duration) ([]SolveStep, bool) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var steps []SolveStep
|
||||
stepNum := 0
|
||||
|
||||
var solve func(*Grid) bool
|
||||
solve = func(cur *Grid) bool {
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
row, col, ok := findMostConstrained(*cur)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
cands := candidates(*cur, row, col)
|
||||
if len(cands) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range cands {
|
||||
stepNum++
|
||||
steps = append(steps, SolveStep{
|
||||
Row: row,
|
||||
Col: col,
|
||||
Value: v,
|
||||
Candidates: cands,
|
||||
StepNumber: stepNum,
|
||||
})
|
||||
cur[row][col] = v
|
||||
if solve(cur) {
|
||||
return true
|
||||
}
|
||||
cur[row][col] = 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
copyGrid := g
|
||||
success := solve(©Grid)
|
||||
return steps, success
|
||||
}
|
||||
|
||||
// GetAllSolutions returns all possible solutions for a puzzle (up to maxSolutions).
|
||||
func GetAllSolutions(g Grid, timeout time.Duration, maxSolutions int) []Grid {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var solutions []Grid
|
||||
|
||||
var dfs func(*Grid)
|
||||
dfs = func(cur *Grid) {
|
||||
if time.Now().After(deadline) || len(solutions) >= maxSolutions {
|
||||
return
|
||||
}
|
||||
row, col, ok := findMostConstrained(*cur)
|
||||
if !ok {
|
||||
// Found a solution, save a copy
|
||||
var solution Grid
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
solution[r][c] = cur[r][c]
|
||||
}
|
||||
}
|
||||
solutions = append(solutions, solution)
|
||||
return
|
||||
}
|
||||
cands := candidates(*cur, row, col)
|
||||
for _, v := range cands {
|
||||
cur[row][col] = v
|
||||
dfs(cur)
|
||||
cur[row][col] = 0
|
||||
}
|
||||
}
|
||||
|
||||
copyGrid := g
|
||||
dfs(©Grid)
|
||||
return solutions
|
||||
}
|
||||
184
internal/stats/stats.go
Normal file
184
internal/stats/stats.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stats tracks player statistics and achievements
|
||||
type Stats struct {
|
||||
TotalGames int `json:"totalGames"`
|
||||
CompletedGames int `json:"completedGames"`
|
||||
CurrentStreak int `json:"currentStreak"`
|
||||
BestStreak int `json:"bestStreak"`
|
||||
LastPlayedDate string `json:"lastPlayedDate"` // YYYY-MM-DD format
|
||||
BestTimes map[string]int `json:"bestTimes"` // difficulty -> seconds
|
||||
CompletionCounts map[string]int `json:"completionCounts"`
|
||||
HintsUsed int `json:"hintsUsed"`
|
||||
RecentGames []GameRecord `json:"recentGames"`
|
||||
DailyHistory map[string]GameRecord `json:"dailyHistory"` // date -> game record
|
||||
}
|
||||
|
||||
// GameRecord represents a completed game
|
||||
type GameRecord struct {
|
||||
Difficulty string `json:"difficulty"`
|
||||
Completed bool `json:"completed"`
|
||||
Time int `json:"time"` // seconds
|
||||
HintsUsed int `json:"hintsUsed"`
|
||||
Date time.Time `json:"date"`
|
||||
IsDaily bool `json:"isDaily"`
|
||||
DailySeed string `json:"dailySeed,omitempty"`
|
||||
}
|
||||
|
||||
// Default returns a new Stats with zero values
|
||||
func Default() Stats {
|
||||
return Stats{
|
||||
BestTimes: make(map[string]int),
|
||||
CompletionCounts: make(map[string]int),
|
||||
RecentGames: []GameRecord{},
|
||||
DailyHistory: make(map[string]GameRecord),
|
||||
}
|
||||
}
|
||||
|
||||
// path returns the stats file path
|
||||
func path() (string, error) {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(h, ".termdoku", "stats.json"), nil
|
||||
}
|
||||
|
||||
// Load reads stats from disk
|
||||
func Load() (Stats, error) {
|
||||
st := Default()
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return st, nil
|
||||
}
|
||||
return st, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
return st, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Save writes stats to disk
|
||||
func Save(st Stats) error {
|
||||
p, err := path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
|
||||
// RecordGame records a completed or abandoned game
|
||||
func (s *Stats) RecordGame(record GameRecord) {
|
||||
s.TotalGames++
|
||||
|
||||
if record.Completed {
|
||||
s.CompletedGames++
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if s.LastPlayedDate == "" {
|
||||
s.CurrentStreak = 1
|
||||
} else {
|
||||
lastDate, _ := time.Parse("2006-01-02", s.LastPlayedDate)
|
||||
daysDiff := int(time.Since(lastDate).Hours() / 24)
|
||||
switch daysDiff {
|
||||
case 0:
|
||||
// Same day, maintain streak
|
||||
case 1:
|
||||
// Consecutive day
|
||||
s.CurrentStreak++
|
||||
default:
|
||||
// Streak broken
|
||||
s.CurrentStreak = 1
|
||||
}
|
||||
}
|
||||
s.LastPlayedDate = today
|
||||
|
||||
if s.CurrentStreak > s.BestStreak {
|
||||
s.BestStreak = s.CurrentStreak
|
||||
}
|
||||
|
||||
if record.Time > 0 {
|
||||
if existing, ok := s.BestTimes[record.Difficulty]; !ok || record.Time < existing {
|
||||
s.BestTimes[record.Difficulty] = record.Time
|
||||
}
|
||||
}
|
||||
s.CompletionCounts[record.Difficulty]++
|
||||
|
||||
if record.IsDaily && record.DailySeed != "" {
|
||||
s.DailyHistory[record.DailySeed] = record
|
||||
}
|
||||
}
|
||||
|
||||
// Track hints
|
||||
s.HintsUsed += record.HintsUsed
|
||||
|
||||
// Add to recent games (keep last 50)
|
||||
s.RecentGames = append([]GameRecord{record}, s.RecentGames...)
|
||||
if len(s.RecentGames) > 50 {
|
||||
s.RecentGames = s.RecentGames[:50]
|
||||
}
|
||||
}
|
||||
|
||||
// GetLeaderboard returns top N game records by time for a difficulty
|
||||
func (s *Stats) GetLeaderboard(difficulty string, limit int) []GameRecord {
|
||||
var records []GameRecord
|
||||
for _, game := range s.RecentGames {
|
||||
if game.Difficulty == difficulty && game.Completed && game.Time > 0 {
|
||||
records = append(records, game)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by time (ascending)
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].Time < records[j].Time
|
||||
})
|
||||
|
||||
if len(records) > limit {
|
||||
records = records[:limit]
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
// HasPlayedDaily checks if the user has played today's daily
|
||||
func (s *Stats) HasPlayedDaily(seed string) bool {
|
||||
_, ok := s.DailyHistory[seed]
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetDailyRecord returns the record for a specific daily seed
|
||||
func (s *Stats) GetDailyRecord(seed string) (GameRecord, bool) {
|
||||
record, ok := s.DailyHistory[seed]
|
||||
return record, ok
|
||||
}
|
||||
|
||||
// FormatTime formats seconds into MM:SS
|
||||
func FormatTime(seconds int) string {
|
||||
mins := seconds / 60
|
||||
secs := seconds % 60
|
||||
return fmt.Sprintf("%02d:%02d", mins, secs)
|
||||
}
|
||||
292
internal/theme/theme.go
Normal file
292
internal/theme/theme.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
type Palette struct {
|
||||
Background string
|
||||
Foreground string
|
||||
GridLine string
|
||||
CellBaseBG string
|
||||
CellBaseFG string
|
||||
CellFixedFG string
|
||||
CellFixedBG string
|
||||
CellSelectedBG string
|
||||
CellSelectedFG string
|
||||
CellDuplicateBG string
|
||||
CellConflictBG string
|
||||
Accent string
|
||||
}
|
||||
|
||||
type Theme struct {
|
||||
Name string
|
||||
Palette Palette
|
||||
}
|
||||
|
||||
// Light defines a theme inspired by Solarized Light.
|
||||
func Light() Theme {
|
||||
return Theme{
|
||||
Name: "solarized-light",
|
||||
Palette: Palette{
|
||||
Background: "#fdf6e3", // base3
|
||||
Foreground: "#586e75", // base01
|
||||
GridLine: "#93a1a1", // base1
|
||||
CellBaseBG: "",
|
||||
CellBaseFG: "#586e75", // base01
|
||||
CellFixedFG: "#839496", // base0
|
||||
CellFixedBG: "",
|
||||
CellSelectedBG: "#eee8d5", // base2
|
||||
CellSelectedFG: "#586e75", // base01
|
||||
CellDuplicateBG: "#f5e8c1", // Slightly darker variant
|
||||
CellConflictBG: "#ffe0e0", // Reddish conflict, still light
|
||||
Accent: "#dc322f", // red
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Darcula returns a dark theme inspired by Darcula.
|
||||
func Darcula() Theme {
|
||||
return Theme{
|
||||
Name: "dracula",
|
||||
Palette: Palette{
|
||||
Background: "#282a36", // background
|
||||
Foreground: "#f8f8f2", // foreground
|
||||
GridLine: "#44475a", // current line
|
||||
CellBaseBG: "",
|
||||
CellBaseFG: "#f8f8f2", // foreground
|
||||
CellFixedFG: "#6272a4", // comment
|
||||
CellFixedBG: "",
|
||||
CellSelectedBG: "#44475a", // current line
|
||||
CellSelectedFG: "#f8f8f2", // foreground
|
||||
CellDuplicateBG: "#50fa7b", // green - using a highlight for duplicate
|
||||
CellConflictBG: "#ff5555", // red
|
||||
Accent: "#bd93f9", // purple
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DetectTheme automatically detects the terminal background and returns appropriate theme
|
||||
func DetectTheme() Theme {
|
||||
if hasLightBackground() {
|
||||
return Light()
|
||||
}
|
||||
|
||||
return Darcula()
|
||||
}
|
||||
|
||||
// hasLightBackground attempts to detect if the terminal has a light background
|
||||
func hasLightBackground() bool {
|
||||
// Primary detection using termenv
|
||||
if !termenv.HasDarkBackground() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Additional environment variable checks
|
||||
term := strings.ToLower(os.Getenv("TERM"))
|
||||
colorterm := strings.ToLower(os.Getenv("COLORTERM"))
|
||||
termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
|
||||
|
||||
// Check for light theme indicators in environment variables
|
||||
lightIndicators := []string{"light", "bright", "white"}
|
||||
for _, indicator := range lightIndicators {
|
||||
if strings.Contains(term, indicator) ||
|
||||
strings.Contains(colorterm, indicator) ||
|
||||
strings.Contains(termProgram, indicator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check iTerm2 profile
|
||||
if iterm2Profile := strings.ToLower(os.Getenv("ITERM_PROFILE")); iterm2Profile != "" {
|
||||
for _, indicator := range lightIndicators {
|
||||
if strings.Contains(iterm2Profile, indicator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AdaptiveColors provides theme-aware color mappings
|
||||
type AdaptiveColors struct {
|
||||
theme Theme
|
||||
}
|
||||
|
||||
func NewAdaptiveColors(t Theme) AdaptiveColors {
|
||||
return AdaptiveColors{theme: t}
|
||||
}
|
||||
|
||||
// GetDifficultyColors returns colors for each difficulty level adapted to the theme
|
||||
func (ac AdaptiveColors) GetDifficultyColors() map[string]string {
|
||||
if ac.theme.Name == "solarized-light" {
|
||||
// Solarized Light theme colors
|
||||
return map[string]string{
|
||||
"Easy": "#2aa198", // cyan
|
||||
"Normal": "#859900", // green
|
||||
"Hard": "#cb4b16", // orange
|
||||
"Lunatic": "#6c71c4", // violet
|
||||
"Daily": "#859900", // green
|
||||
}
|
||||
}
|
||||
// Dracula theme colors
|
||||
return map[string]string{
|
||||
"Easy": "#8be9fd", // cyan
|
||||
"Normal": "#50fa7b", // green
|
||||
"Hard": "#ffb86c", // orange
|
||||
"Lunatic": "#bd93f9", // purple
|
||||
"Daily": "#50fa7b", // green
|
||||
}
|
||||
}
|
||||
|
||||
// GetGradientColors returns gradient color pairs adapted to the theme
|
||||
func (ac AdaptiveColors) GetGradientColors() map[string][2]string {
|
||||
if ac.theme.Name == "solarized-light" {
|
||||
// Solarized Light theme gradients
|
||||
return map[string][2]string{
|
||||
"banner": {"#6c71c4", "#b58900"}, // violet to yellow
|
||||
"easy": {"#2aa198", "#268bd2"}, // cyan to blue
|
||||
"normal": {"#859900", "#cb4b16"}, // green to orange
|
||||
"daily": {"#859900", "#cb4b16"}, // green to orange
|
||||
"hard": {"#dc322f", "#cb4b16"}, // red to orange
|
||||
"lunatic": {"#6c71c4", "#d33682"}, // violet to magenta
|
||||
"complete": {"#859900", "#268bd2"}, // success green to blue
|
||||
}
|
||||
}
|
||||
// Dracula theme gradients
|
||||
return map[string][2]string{
|
||||
"banner": {"#bd93f9", "#ff79c6"}, // purple to pink
|
||||
"easy": {"#8be9fd", "#6272a4"}, // cyan to comment
|
||||
"normal": {"#50fa7b", "#ffb86c"}, // green to orange
|
||||
"daily": {"#50fa7b", "#ffb86c"}, // green to orange
|
||||
"hard": {"#ffb86c", "#ff5555"}, // orange to red
|
||||
"lunatic": {"#bd93f9", "#ff79c6"}, // purple to pink
|
||||
"complete": {"#50fa7b", "#ff79c6"}, // green to pink
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccentColors returns various accent colors adapted to the theme
|
||||
func (ac AdaptiveColors) GetAccentColors() map[string]string {
|
||||
if ac.theme.Name == "solarized-light" {
|
||||
// Solarized Light theme accents
|
||||
return map[string]string{
|
||||
"selected": "#cb4b16", // orange
|
||||
"panel": "#839496", // base0
|
||||
"success": "#859900", // green
|
||||
"error": "#dc322f", // red
|
||||
}
|
||||
}
|
||||
// Dracula theme accents
|
||||
return map[string]string{
|
||||
"selected": "#ff79c6", // pink
|
||||
"panel": "#44475a", // current line
|
||||
"success": "#50fa7b", // green
|
||||
"error": "#ff5555", // red
|
||||
}
|
||||
}
|
||||
|
||||
func BaseStyle(t Theme) lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(t.Palette.Foreground)).Background(lipgloss.Color(t.Palette.Background))
|
||||
}
|
||||
|
||||
// LoadCustomTheme loads a custom theme from ~/.termdoku/themes/
|
||||
func LoadCustomTheme(name string) (Theme, error) {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return Theme{}, err
|
||||
}
|
||||
|
||||
themePath := filepath.Join(h, ".termdoku", "themes", name+".toml")
|
||||
data, err := os.ReadFile(themePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Theme{}, errors.New("custom theme not found: " + name)
|
||||
}
|
||||
return Theme{}, err
|
||||
}
|
||||
|
||||
var theme Theme
|
||||
if err := toml.Unmarshal(data, &theme); err != nil {
|
||||
return Theme{}, err
|
||||
}
|
||||
|
||||
// Set name if not specified in file
|
||||
if theme.Name == "" {
|
||||
theme.Name = name
|
||||
}
|
||||
|
||||
return theme, nil
|
||||
}
|
||||
|
||||
// GetTheme returns the appropriate theme based on config or auto-detection
|
||||
func GetTheme(configTheme string) Theme {
|
||||
switch configTheme {
|
||||
case "light":
|
||||
return Light()
|
||||
case "dark":
|
||||
return Darcula()
|
||||
case "auto", "":
|
||||
return DetectTheme()
|
||||
default:
|
||||
if theme, err := LoadCustomTheme(configTheme); err == nil {
|
||||
return theme
|
||||
}
|
||||
// Fall back to auto-detection
|
||||
return DetectTheme()
|
||||
}
|
||||
}
|
||||
|
||||
// CreateExampleTheme creates an example custom theme file for users
|
||||
func CreateExampleTheme() error {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
themesDir := filepath.Join(h, ".termdoku", "themes")
|
||||
if err := os.MkdirAll(themesDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
examplePath := filepath.Join(themesDir, "example.toml")
|
||||
|
||||
// Don't overwrite if exists
|
||||
if _, err := os.Stat(examplePath); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Example theme inspired by Monokai
|
||||
example := Theme{
|
||||
Name: "monokai-example",
|
||||
Palette: Palette{
|
||||
Background: "#272822",
|
||||
Foreground: "#f8f8f2",
|
||||
GridLine: "#49483e",
|
||||
CellBaseBG: "",
|
||||
CellBaseFG: "#f8f8f2",
|
||||
CellFixedFG: "#75715e",
|
||||
CellFixedBG: "",
|
||||
CellSelectedBG: "#3e3d32",
|
||||
CellSelectedFG: "#f8f8f2",
|
||||
CellDuplicateBG: "#a6e22e", // Green for duplicates
|
||||
CellConflictBG: "#f92672", // Pink/Red for conflicts
|
||||
Accent: "#e6db74", // Yellow accent
|
||||
},
|
||||
}
|
||||
|
||||
data, err := toml.Marshal(example)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(examplePath, data, 0o644)
|
||||
}
|
||||
16
internal/ui/assets/banner.txt
Normal file
16
internal/ui/assets/banner.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
,----,
|
||||
,/ .`|
|
||||
,` .' : ____ ,---, ,-.
|
||||
; ; / ,' , `. .' .' `\ ,--/ /|
|
||||
.'___,/ ,' __ ,-. ,-+-,.' _ |,---.' \ ,---. ,--. :/ | ,--,
|
||||
| : | ,' ,'/ /| ,-+-. ; , ||| | .`\ | ' ,'\ : : ' / ,'_ /|
|
||||
; |.'; ; ,---. ' | |' | ,--.'|' | ||: : | ' | / / || ' / .--. | | :
|
||||
`----' | | / \ | | ,'| | ,', | |,| ' ' ; :. ; ,. :' | : ,'_ /| : . |
|
||||
' : ; / / |' : / | | / | |--' ' | ; . |' | |: :| | \ | ' | | . .
|
||||
| | '. ' / || | ' | : | | , | | : | '' | .; :' : |. \ | | ' | | |
|
||||
' : |' ; /|; : | | : | |/ ' : | / ; | : || | ' \ \: | : ; ; |
|
||||
; |.' ' | / || , ; | | |`-' | | '` ,/ \ \ / ' : |--' ' : `--' \
|
||||
'---' | : | ---' | ;/ ; : .' `----' ; |,' : , .-./
|
||||
\ \ / '---' | ,.' '--' `--`----'
|
||||
`----' '---'
|
||||
|
||||
20
internal/ui/example.go
Normal file
20
internal/ui/example.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"termdoku/internal/theme"
|
||||
)
|
||||
|
||||
// ExampleRenderSample returns a sample rendering string for docs/tests.
|
||||
func ExampleRenderSample() string {
|
||||
_th := theme.Darcula()
|
||||
styles := BuildStyles(_th)
|
||||
var b strings.Builder
|
||||
b.WriteString(styles.CellSelected.Render("5"))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(styles.CellDuplicate.Render("5"))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(styles.CellConflict.Render("3"))
|
||||
b.WriteString("\n")
|
||||
return styles.App.Render(b.String())
|
||||
}
|
||||
473
internal/ui/game.go
Normal file
473
internal/ui/game.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"termdoku/internal/config"
|
||||
"termdoku/internal/game"
|
||||
"termdoku/internal/generator"
|
||||
"termdoku/internal/savegame"
|
||||
"termdoku/internal/solver"
|
||||
"termdoku/internal/theme"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type timerTickMsg struct{}
|
||||
|
||||
type flashDoneMsg struct{ Row, Col int }
|
||||
|
||||
type Model struct {
|
||||
keymap KeyMap
|
||||
styles UIStyles
|
||||
theme theme.Theme
|
||||
|
||||
board game.Board
|
||||
solution game.Grid
|
||||
cursorRow int
|
||||
cursorCol int
|
||||
autoCheck bool
|
||||
timerEnabled bool
|
||||
startTime time.Time
|
||||
elapsed time.Duration
|
||||
completed bool
|
||||
paused bool
|
||||
difficulty string
|
||||
|
||||
undoStack []game.Move
|
||||
redoStack []game.Move
|
||||
flashes map[[2]int]time.Time
|
||||
showHelp bool
|
||||
hintsUsed int
|
||||
noteMode bool
|
||||
notes map[[2]int][]uint8 // cell -> candidate numbers
|
||||
showWinAnim bool
|
||||
winAnimStart time.Time
|
||||
}
|
||||
|
||||
func New(p generator.Grid, th theme.Theme, cfg config.Config) Model {
|
||||
b := game.NewBoardFromPuzzle(game.Grid(p))
|
||||
// Solve once for auto-check
|
||||
sg := b.Values
|
||||
if s := solveCopy(b.Values); s != nil {
|
||||
sg = *s
|
||||
}
|
||||
km := DefaultKeyMap()
|
||||
km.ApplyBindings(cfg.Bindings)
|
||||
m := Model{
|
||||
keymap: km,
|
||||
styles: BuildStyles(th),
|
||||
theme: th,
|
||||
board: b,
|
||||
solution: sg,
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
autoCheck: cfg.AutoCheck,
|
||||
timerEnabled: cfg.TimerEnabled,
|
||||
startTime: time.Now(),
|
||||
flashes: map[[2]int]time.Time{},
|
||||
notes: make(map[[2]int][]uint8),
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func solveCopy(g game.Grid) *game.Grid {
|
||||
var sg solver.Grid
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
sg[r][c] = g[r][c]
|
||||
}
|
||||
}
|
||||
if solver.Solve(&sg, 2*time.Second) {
|
||||
var out game.Grid
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
out[r][c] = sg[r][c]
|
||||
}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
if m.timerEnabled {
|
||||
cmds = append(cmds, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} }))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) View() string { return Render(m) }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
case timerTickMsg:
|
||||
if m.timerEnabled && !m.completed {
|
||||
m.elapsed = time.Since(m.startTime)
|
||||
return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} })
|
||||
}
|
||||
return m, nil
|
||||
case flashDoneMsg:
|
||||
delete(m.flashes, [2]int{msg.Row, msg.Col})
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := msg
|
||||
|
||||
// Help overlay toggle
|
||||
if key.Matches(k, m.keymap.Help) {
|
||||
m.showHelp = !m.showHelp
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// When help is shown, only allow closing it
|
||||
if m.showHelp {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Pause toggle
|
||||
if key.Matches(k, m.keymap.Pause) {
|
||||
m.paused = !m.paused
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// When paused, only allow unpause or quit
|
||||
if m.paused {
|
||||
if k.String() == "q" || k.String() == "esc" || k.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Toggle features
|
||||
if key.Matches(k, m.keymap.ToggleAuto) {
|
||||
m.autoCheck = !m.autoCheck
|
||||
return m, nil
|
||||
}
|
||||
if key.Matches(k, m.keymap.ToggleTimer) {
|
||||
m.timerEnabled = !m.timerEnabled
|
||||
if m.timerEnabled && !m.completed {
|
||||
m.startTime = time.Now().Add(-m.elapsed)
|
||||
return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} })
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if key.Matches(k, m.keymap.ToggleNote) {
|
||||
m.noteMode = !m.noteMode
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Hint
|
||||
if key.Matches(k, m.keymap.Hint) && !m.completed {
|
||||
m = m.applyHint()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Undo/Redo
|
||||
if key.Matches(k, m.keymap.Undo) {
|
||||
m = m.applyUndo()
|
||||
return m, nil
|
||||
}
|
||||
if key.Matches(k, m.keymap.Redo) {
|
||||
m = m.applyRedo()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Save/Load
|
||||
if key.Matches(k, m.keymap.Save) && !m.completed {
|
||||
_ = m.saveGame() // Ignore errors for now
|
||||
return m, nil
|
||||
}
|
||||
if key.Matches(k, m.keymap.Load) {
|
||||
if loadedModel, err := m.loadGame(); err == nil {
|
||||
m = loadedModel
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
s := k.String()
|
||||
switch s {
|
||||
case "up", "k":
|
||||
m.cursorRow = clamp(m.cursorRow-1, 0, 8)
|
||||
case "down", "j":
|
||||
m.cursorRow = clamp(m.cursorRow+1, 0, 8)
|
||||
case "left", "h":
|
||||
m.cursorCol = clamp(m.cursorCol-1, 0, 8)
|
||||
case "right", "l":
|
||||
m.cursorCol = clamp(m.cursorCol+1, 0, 8)
|
||||
case " ", "0":
|
||||
return m.applyInput(0)
|
||||
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
|
||||
v := uint8(s[0] - '0')
|
||||
return m.applyInput(v)
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) applyInput(v uint8) (tea.Model, tea.Cmd) {
|
||||
if m.board.IsGiven(m.cursorRow, m.cursorCol) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Note mode: toggle candidate number
|
||||
if m.noteMode && v != 0 {
|
||||
key := [2]int{m.cursorRow, m.cursorCol}
|
||||
notes := m.notes[key]
|
||||
|
||||
// Toggle the note
|
||||
found := false
|
||||
for i, n := range notes {
|
||||
if n == v {
|
||||
// Remove note
|
||||
notes = append(notes[:i], notes[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// Add note
|
||||
notes = append(notes, v)
|
||||
}
|
||||
|
||||
if len(notes) > 0 {
|
||||
m.notes[key] = notes
|
||||
} else {
|
||||
delete(m.notes, key)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Normal mode: set value
|
||||
prev, ok := m.board.SetValue(m.cursorRow, m.cursorCol, v)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Clear notes for this cell when setting a value
|
||||
if v != 0 {
|
||||
delete(m.notes, [2]int{m.cursorRow, m.cursorCol})
|
||||
}
|
||||
|
||||
mv := game.Move{Row: m.cursorRow, Col: m.cursorCol, Prev: prev, Next: v, At: time.Now()}
|
||||
m.undoStack = append(m.undoStack, mv)
|
||||
m.redoStack = nil
|
||||
m.flashes[[2]int{m.cursorRow, m.cursorCol}] = time.Now().Add(120 * time.Millisecond)
|
||||
|
||||
if isSolved(m.board.Values, m.solution) {
|
||||
m.completed = true
|
||||
m.showWinAnim = true
|
||||
m.winAnimStart = time.Now()
|
||||
}
|
||||
return m, tea.Tick(130*time.Millisecond, func(time.Time) tea.Msg { return flashDoneMsg{Row: mv.Row, Col: mv.Col} })
|
||||
}
|
||||
|
||||
func (m Model) applyUndo() Model {
|
||||
if len(m.undoStack) == 0 {
|
||||
return m
|
||||
}
|
||||
last := m.undoStack[len(m.undoStack)-1]
|
||||
m.undoStack = m.undoStack[:len(m.undoStack)-1]
|
||||
m.board.Values[last.Row][last.Col] = last.Prev
|
||||
m.redoStack = append(m.redoStack, last)
|
||||
m.cursorRow, m.cursorCol = last.Row, last.Col
|
||||
m.completed = isSolved(m.board.Values, m.solution)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) applyRedo() Model {
|
||||
if len(m.redoStack) == 0 {
|
||||
return m
|
||||
}
|
||||
last := m.redoStack[len(m.redoStack)-1]
|
||||
m.redoStack = m.redoStack[:len(m.redoStack)-1]
|
||||
m.board.Values[last.Row][last.Col] = last.Next
|
||||
m.undoStack = append(m.undoStack, last)
|
||||
m.cursorRow, m.cursorCol = last.Row, last.Col
|
||||
m.completed = isSolved(m.board.Values, m.solution)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) applyHint() Model {
|
||||
// Find first empty cell and fill it with solution value
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if !m.board.Given[r][c] && m.board.Values[r][c] == 0 {
|
||||
solutionVal := m.solution[r][c]
|
||||
if solutionVal != 0 {
|
||||
m.board.Values[r][c] = solutionVal
|
||||
m.cursorRow, m.cursorCol = r, c
|
||||
m.hintsUsed++
|
||||
m.flashes[[2]int{r, c}] = time.Now().Add(500 * time.Millisecond)
|
||||
|
||||
// Clear notes for hinted cell
|
||||
delete(m.notes, [2]int{r, c})
|
||||
|
||||
if isSolved(m.board.Values, m.solution) {
|
||||
m.completed = true
|
||||
m.showWinAnim = true
|
||||
m.winAnimStart = time.Now()
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) saveGame() error {
|
||||
// Convert notes map to string keys for JSON
|
||||
notesJSON := make(map[string][]uint8)
|
||||
for key, notes := range m.notes {
|
||||
keyStr := strconv.Itoa(key[0]) + "," + strconv.Itoa(key[1])
|
||||
notesJSON[keyStr] = notes
|
||||
}
|
||||
|
||||
sg := savegame.SavedGame{
|
||||
Board: m.board.Values,
|
||||
Solution: m.solution,
|
||||
Given: m.board.Given,
|
||||
Difficulty: m.difficulty,
|
||||
Elapsed: int64(m.elapsed.Seconds()),
|
||||
StartTime: m.startTime,
|
||||
HintsUsed: m.hintsUsed,
|
||||
Notes: notesJSON,
|
||||
SavedAt: time.Now(),
|
||||
AutoCheck: m.autoCheck,
|
||||
TimerEnabled: m.timerEnabled,
|
||||
}
|
||||
return savegame.Save(sg)
|
||||
}
|
||||
|
||||
func (m Model) loadGame() (Model, error) {
|
||||
sg, err := savegame.Load()
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
// Restore board
|
||||
m.board.Values = sg.Board
|
||||
m.board.Given = sg.Given
|
||||
m.solution = sg.Solution
|
||||
m.difficulty = sg.Difficulty
|
||||
m.hintsUsed = sg.HintsUsed
|
||||
m.autoCheck = sg.AutoCheck
|
||||
m.timerEnabled = sg.TimerEnabled
|
||||
|
||||
// Restore notes (convert string keys back to [2]int)
|
||||
m.notes = make(map[[2]int][]uint8)
|
||||
for keyStr, notes := range sg.Notes {
|
||||
// Parse "r,c" format
|
||||
var r, c int
|
||||
fmt.Sscanf(keyStr, "%d,%d", &r, &c)
|
||||
m.notes[[2]int{r, c}] = notes
|
||||
}
|
||||
|
||||
// Restore timer state
|
||||
if m.timerEnabled {
|
||||
m.elapsed = time.Duration(sg.Elapsed) * time.Second
|
||||
m.startTime = time.Now().Add(-m.elapsed)
|
||||
}
|
||||
|
||||
m.completed = isSolved(m.board.Values, m.solution)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (m Model) StatusLine() string {
|
||||
// Completed UI
|
||||
if m.completed {
|
||||
adaptiveColors := theme.NewAdaptiveColors(m.theme)
|
||||
gradientColors := adaptiveColors.GetGradientColors()
|
||||
completeGrad := gradientColors["complete"]
|
||||
var completeText string
|
||||
if m.timerEnabled {
|
||||
secs := int(m.elapsed.Truncate(time.Second).Seconds())
|
||||
mins := (secs / 60) % 100
|
||||
s := secs % 60
|
||||
timeStr := fmt.Sprintf("%02d:%02d", mins, s)
|
||||
if m.hintsUsed > 0 {
|
||||
completeText = fmt.Sprintf("✭ Clear %s (%d hints) ! Press 'm' for menu ✭", timeStr, m.hintsUsed)
|
||||
} else {
|
||||
completeText = fmt.Sprintf("✭ Clear %s ! Press 'm' for menu ✭", timeStr)
|
||||
}
|
||||
} else {
|
||||
completeText = "✭ Clear! Press 'm' for menu ✭"
|
||||
}
|
||||
return gradientText(completeText, completeGrad[0], completeGrad[1])
|
||||
}
|
||||
// All filled but not solved → Try again
|
||||
if allFilled(m.board.Values) && !isSolved(m.board.Values, m.solution) {
|
||||
return m.styles.StatusError.Render("✭ Try again... ✭")
|
||||
}
|
||||
// Normal status (fixed width segments)
|
||||
var parts []string
|
||||
|
||||
// Timer
|
||||
if m.timerEnabled {
|
||||
secs := int(m.elapsed.Truncate(time.Second).Seconds())
|
||||
mins := (secs / 60) % 100
|
||||
s := secs % 60
|
||||
timeValue := fmt.Sprintf("%02d:%02d", mins, s)
|
||||
parts = append(parts, m.styles.Status.Render("Timer: ")+m.styles.BoolTrue.Render(timeValue))
|
||||
}
|
||||
|
||||
// Hints used
|
||||
if m.hintsUsed > 0 {
|
||||
parts = append(parts, m.styles.Status.Render(fmt.Sprintf("Hints: %d", m.hintsUsed)))
|
||||
}
|
||||
|
||||
// Note mode indicator
|
||||
if m.noteMode {
|
||||
parts = append(parts, m.styles.BoolTrue.Render("NOTE MODE"))
|
||||
}
|
||||
|
||||
// Help hint
|
||||
parts = append(parts, m.styles.Status.Render("Help: ?"))
|
||||
|
||||
separator := m.styles.Status.Render(" | ")
|
||||
return strings.Join(parts, separator)
|
||||
}
|
||||
|
||||
func allFilled(g game.Grid) bool {
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if g[r][c] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isSolved(cur game.Grid, sol game.Grid) bool {
|
||||
for r := 0; r < 9; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
if cur[r][c] != sol[r][c] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
97
internal/ui/keymap.go
Normal file
97
internal/ui/keymap.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type KeyMap struct {
|
||||
Up, Down, Left, Right key.Binding
|
||||
Undo, Redo key.Binding
|
||||
ToggleAuto key.Binding
|
||||
ToggleTimer key.Binding
|
||||
Help key.Binding
|
||||
MainMenu key.Binding
|
||||
Hint key.Binding
|
||||
Pause key.Binding
|
||||
ToggleNote key.Binding
|
||||
Save key.Binding
|
||||
Load key.Binding
|
||||
}
|
||||
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "Up")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "Down")),
|
||||
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "Left")),
|
||||
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "Right")),
|
||||
Undo: key.NewBinding(key.WithKeys("ctrl+z", "u"), key.WithHelp("u/Ctrl+Z", "Undo")),
|
||||
Redo: key.NewBinding(key.WithKeys("ctrl+y", "ctrl+r"), key.WithHelp("Ctrl+Y/R", "Redo")),
|
||||
ToggleAuto: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "Auto-Check")),
|
||||
ToggleTimer: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "Timer")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "Help")),
|
||||
MainMenu: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "Main Menu")),
|
||||
Hint: key.NewBinding(key.WithKeys("ctrl+h"), key.WithHelp("Ctrl+H", "Hint")),
|
||||
Pause: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "Pause")),
|
||||
ToggleNote: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "Note Mode")),
|
||||
Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("Ctrl+S", "Save")),
|
||||
Load: key.NewBinding(key.WithKeys("ctrl+l"), key.WithHelp("Ctrl+L", "Load")),
|
||||
}
|
||||
}
|
||||
|
||||
func (km *KeyMap) ApplyBindings(bindings map[string][]string) {
|
||||
if bindings == nil {
|
||||
return
|
||||
}
|
||||
set := func(b *key.Binding, keys []string, help string) {
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
*b = key.NewBinding(key.WithKeys(keys...), key.WithHelp(strings.Join(keys, "/"), help))
|
||||
}
|
||||
if v, ok := bindings["up"]; ok {
|
||||
set(&km.Up, v, "Up")
|
||||
}
|
||||
if v, ok := bindings["down"]; ok {
|
||||
set(&km.Down, v, "Down")
|
||||
}
|
||||
if v, ok := bindings["left"]; ok {
|
||||
set(&km.Left, v, "Left")
|
||||
}
|
||||
if v, ok := bindings["right"]; ok {
|
||||
set(&km.Right, v, "Right")
|
||||
}
|
||||
if v, ok := bindings["undo"]; ok {
|
||||
set(&km.Undo, v, "Undo")
|
||||
}
|
||||
if v, ok := bindings["redo"]; ok {
|
||||
set(&km.Redo, v, "Redo")
|
||||
}
|
||||
if v, ok := bindings["auto"]; ok {
|
||||
set(&km.ToggleAuto, v, "Auto-Check")
|
||||
}
|
||||
if v, ok := bindings["timer"]; ok {
|
||||
set(&km.ToggleTimer, v, "Timer")
|
||||
}
|
||||
if v, ok := bindings["help"]; ok {
|
||||
set(&km.Help, v, "Help")
|
||||
}
|
||||
if v, ok := bindings["main"]; ok {
|
||||
set(&km.MainMenu, v, "Main Menu")
|
||||
}
|
||||
if v, ok := bindings["hint"]; ok {
|
||||
set(&km.Hint, v, "Hint")
|
||||
}
|
||||
if v, ok := bindings["pause"]; ok {
|
||||
set(&km.Pause, v, "Pause")
|
||||
}
|
||||
if v, ok := bindings["note"]; ok {
|
||||
set(&km.ToggleNote, v, "Note Mode")
|
||||
}
|
||||
if v, ok := bindings["save"]; ok {
|
||||
set(&km.Save, v, "Save")
|
||||
}
|
||||
if v, ok := bindings["load"]; ok {
|
||||
set(&km.Load, v, "Load")
|
||||
}
|
||||
}
|
||||
634
internal/ui/menu.go
Normal file
634
internal/ui/menu.go
Normal file
@@ -0,0 +1,634 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"termdoku/internal/achievements"
|
||||
"termdoku/internal/config"
|
||||
"termdoku/internal/generator"
|
||||
"termdoku/internal/stats"
|
||||
"termdoku/internal/theme"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
//go:embed assets/banner.txt
|
||||
var bannerArt string
|
||||
|
||||
type appState int
|
||||
|
||||
const (
|
||||
stateMenu appState = iota
|
||||
stateGame
|
||||
stateStats
|
||||
stateAchievements
|
||||
stateLeaderboard
|
||||
stateProfile
|
||||
stateProfileSubmenu
|
||||
stateDatabase
|
||||
)
|
||||
|
||||
type App struct {
|
||||
state appState
|
||||
cfg config.Config
|
||||
th theme.Theme
|
||||
styles UIStyles
|
||||
stats stats.Stats
|
||||
achievements *achievements.Manager
|
||||
|
||||
menuItems []string
|
||||
selectedIdx int
|
||||
autoCheck bool
|
||||
timerEnabled bool
|
||||
|
||||
width int
|
||||
height int
|
||||
|
||||
currentDiff string
|
||||
game Model
|
||||
|
||||
// Profile submenu
|
||||
profileMenuItems []string
|
||||
profileSelectedIdx int
|
||||
}
|
||||
|
||||
func NewApp(cfg config.Config) App {
|
||||
th := theme.GetTheme(cfg.Theme)
|
||||
st, _ := stats.Load()
|
||||
ach, _ := achievements.Load()
|
||||
return App{
|
||||
state: stateMenu,
|
||||
cfg: cfg,
|
||||
th: th,
|
||||
styles: BuildStyles(th),
|
||||
stats: st,
|
||||
achievements: ach,
|
||||
menuItems: []string{"Easy", "Normal", "Hard", "Expert", "Lunatic", "Daily", "Profile"},
|
||||
selectedIdx: 1,
|
||||
autoCheck: cfg.AutoCheck,
|
||||
timerEnabled: cfg.TimerEnabled,
|
||||
profileMenuItems: []string{"Achievements", "Leaderboard"},
|
||||
profileSelectedIdx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) Init() tea.Cmd { return nil }
|
||||
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch a.state {
|
||||
case stateMenu:
|
||||
switch m := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
s := m.String()
|
||||
switch s {
|
||||
case "up", "k":
|
||||
a.selectedIdx = clamp(a.selectedIdx-1, 0, len(a.menuItems)-1)
|
||||
case "down", "j":
|
||||
a.selectedIdx = clamp(a.selectedIdx+1, 0, len(a.menuItems)-1)
|
||||
case "left", "h":
|
||||
a.selectedIdx = clamp(a.selectedIdx-1, 0, len(a.menuItems)-1)
|
||||
case "right", "l":
|
||||
a.selectedIdx = clamp(a.selectedIdx+1, 0, len(a.menuItems)-1)
|
||||
case "a":
|
||||
a.autoCheck = !a.autoCheck
|
||||
case "t":
|
||||
a.timerEnabled = !a.timerEnabled
|
||||
case "enter":
|
||||
sel := a.menuItems[a.selectedIdx]
|
||||
switch sel {
|
||||
case "Achievements":
|
||||
a.state = stateAchievements
|
||||
return a, nil
|
||||
case "Leaderboard":
|
||||
a.state = stateLeaderboard
|
||||
return a, nil
|
||||
case "Profile":
|
||||
a.state = stateProfile
|
||||
return a, nil
|
||||
default:
|
||||
gm, cmd := a.startGame()
|
||||
a.game = gm
|
||||
a.state = stateGame
|
||||
return a, cmd
|
||||
}
|
||||
case "q", "esc", "ctrl+c":
|
||||
return a, tea.Quit
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
}
|
||||
return a, nil
|
||||
case stateStats:
|
||||
switch m := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
s := m.String()
|
||||
switch s {
|
||||
case "m", "q", "esc", "enter":
|
||||
a.state = stateMenu
|
||||
return a, nil
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
}
|
||||
return a, nil
|
||||
case stateGame:
|
||||
// Check if game was just completed
|
||||
wasCompleted := a.game.completed
|
||||
|
||||
// intercept main menu key
|
||||
if kmsg, isKey := msg.(tea.KeyMsg); isKey {
|
||||
if kmsg.String() == "m" {
|
||||
// Record game if it was completed but not yet recorded
|
||||
if a.game.completed && !wasCompleted {
|
||||
a.recordGameCompletion()
|
||||
}
|
||||
a.state = stateMenu
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
gm, cmd := a.game.Update(msg)
|
||||
if v, ok := gm.(Model); ok {
|
||||
// Check if game just became completed
|
||||
if v.completed && !wasCompleted {
|
||||
a.game = v
|
||||
a.recordGameCompletion()
|
||||
return a, cmd
|
||||
}
|
||||
a.game = v
|
||||
}
|
||||
return a, cmd
|
||||
case stateAchievements:
|
||||
switch m := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
s := m.String()
|
||||
switch s {
|
||||
case "m", "q", "esc", "enter":
|
||||
a.state = stateMenu
|
||||
return a, nil
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
}
|
||||
return a, nil
|
||||
case stateLeaderboard:
|
||||
switch m := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
s := m.String()
|
||||
switch s {
|
||||
case "m", "q", "esc", "enter":
|
||||
a.state = stateMenu
|
||||
return a, nil
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
}
|
||||
return a, nil
|
||||
case stateProfile:
|
||||
switch m := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
s := m.String()
|
||||
switch s {
|
||||
case "m", "q", "esc", "enter":
|
||||
a.state = stateMenu
|
||||
return a, nil
|
||||
case "s":
|
||||
a.state = stateProfileSubmenu
|
||||
return a, nil
|
||||
case "d":
|
||||
a.state = stateDatabase
|
||||
return a, nil
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
}
|
||||
return a, nil
|
||||
case stateProfileSubmenu:
|
||||
switch m := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
s := m.String()
|
||||
switch s {
|
||||
case "m", "q", "esc":
|
||||
a.state = stateProfile
|
||||
return a, nil
|
||||
case "up", "k":
|
||||
a.profileSelectedIdx = clamp(a.profileSelectedIdx-1, 0, len(a.profileMenuItems)-1)
|
||||
case "down", "j":
|
||||
a.profileSelectedIdx = clamp(a.profileSelectedIdx+1, 0, len(a.profileMenuItems)-1)
|
||||
case "enter":
|
||||
sel := a.profileMenuItems[a.profileSelectedIdx]
|
||||
switch sel {
|
||||
case "Stats":
|
||||
a.state = stateStats
|
||||
return a, nil
|
||||
case "Achievements":
|
||||
a.state = stateAchievements
|
||||
return a, nil
|
||||
case "Leaderboard":
|
||||
a.state = stateLeaderboard
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
}
|
||||
return a, nil
|
||||
case stateDatabase:
|
||||
switch m := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
s := m.String()
|
||||
switch s {
|
||||
case "m", "q", "esc", "enter":
|
||||
a.state = stateMenu
|
||||
return a, nil
|
||||
case "p":
|
||||
a.state = stateProfile
|
||||
return a, nil
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) View() string {
|
||||
switch a.state {
|
||||
case stateMenu:
|
||||
return a.viewMenu()
|
||||
case stateGame:
|
||||
return a.viewGame()
|
||||
case stateAchievements:
|
||||
return a.viewAchievements()
|
||||
case stateProfile:
|
||||
return a.viewProfile()
|
||||
case stateProfileSubmenu:
|
||||
return a.viewProfileSubmenu()
|
||||
case stateDatabase:
|
||||
return a.viewDatabaseInfo()
|
||||
case stateLeaderboard:
|
||||
return a.viewLeaderboard()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a App) recordGameCompletion() {
|
||||
record := stats.GameRecord{
|
||||
Difficulty: a.currentDiff,
|
||||
Completed: a.game.completed,
|
||||
Time: int(a.game.elapsed.Seconds()),
|
||||
HintsUsed: a.game.hintsUsed,
|
||||
Date: time.Now(),
|
||||
IsDaily: a.currentDiff == "Daily",
|
||||
}
|
||||
if a.currentDiff == "Daily" {
|
||||
record.DailySeed = time.Now().Format("2006-01-02")
|
||||
}
|
||||
a.stats.RecordGame(record)
|
||||
_ = stats.Save(a.stats)
|
||||
|
||||
if a.game.completed {
|
||||
a.achievements.CheckAndUnlock("first_win", a.stats.CompletedGames)
|
||||
|
||||
if a.game.hintsUsed == 0 {
|
||||
a.achievements.CheckAndUnlock("perfectionist", 1)
|
||||
}
|
||||
|
||||
if a.currentDiff == "Easy" && int(a.game.elapsed.Seconds()) < 180 {
|
||||
a.achievements.CheckAndUnlock("speed_demon", 1)
|
||||
}
|
||||
|
||||
if a.currentDiff == "Hard" && !a.autoCheck {
|
||||
a.achievements.CheckAndUnlock("no_mistakes", 1)
|
||||
}
|
||||
|
||||
a.achievements.CheckAndUnlock("streak_master", a.stats.CurrentStreak)
|
||||
a.achievements.CheckAndUnlock("century", a.stats.CompletedGames)
|
||||
|
||||
if a.currentDiff == "Lunatic" {
|
||||
lunaticCount := a.stats.CompletionCounts["Lunatic"]
|
||||
a.achievements.CheckAndUnlock("lunatic_legend", lunaticCount)
|
||||
}
|
||||
|
||||
if a.currentDiff == "Daily" {
|
||||
dailyCount := len(a.stats.DailyHistory)
|
||||
a.achievements.CheckAndUnlock("daily_devotee", dailyCount)
|
||||
}
|
||||
|
||||
_ = achievements.Save(a.achievements)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) startGame() (Model, tea.Cmd) {
|
||||
var g generator.Grid
|
||||
var err error
|
||||
sel := a.menuItems[a.selectedIdx]
|
||||
switch sel {
|
||||
case "Daily":
|
||||
g, err = generator.GenerateDaily(time.Now())
|
||||
case "Easy":
|
||||
g, err = generator.Generate(generator.Easy, "")
|
||||
case "Normal":
|
||||
g, err = generator.Generate(generator.Normal, "")
|
||||
case "Hard":
|
||||
g, err = generator.Generate(generator.Hard, "")
|
||||
case "Expert":
|
||||
g, err = generator.Generate(generator.Expert, "")
|
||||
case "Lunatic":
|
||||
g, err = generator.Generate(generator.Lunatic, "")
|
||||
}
|
||||
if err != nil {
|
||||
return a.game, nil
|
||||
}
|
||||
cfg := a.cfg
|
||||
cfg.AutoCheck = a.autoCheck
|
||||
cfg.TimerEnabled = a.timerEnabled
|
||||
a.currentDiff = sel
|
||||
m := New(g, a.th, cfg)
|
||||
m.difficulty = sel
|
||||
adaptiveColors := theme.NewAdaptiveColors(a.th)
|
||||
diffColors := adaptiveColors.GetDifficultyColors()
|
||||
hex := diffColors[sel]
|
||||
if hex == "" {
|
||||
hex = a.th.Palette.Accent
|
||||
}
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
||||
m.styles.RowSep = style
|
||||
m.styles.ColSep = style
|
||||
m.styles.CellFixed = m.styles.CellFixed.Foreground(lipgloss.Color(hex))
|
||||
return m, m.Init()
|
||||
}
|
||||
|
||||
func (a App) viewMenu() string {
|
||||
banner := bannerArt
|
||||
|
||||
// Options
|
||||
optAC := fmt.Sprintf("Auto-Check (a): %s", boolText(a.styles, a.autoCheck))
|
||||
optTM := fmt.Sprintf("Timer (t): %s", boolText(a.styles, a.timerEnabled))
|
||||
|
||||
// Adaptive colors
|
||||
adaptiveColors := theme.NewAdaptiveColors(a.th)
|
||||
accentColors := adaptiveColors.GetAccentColors()
|
||||
|
||||
// Display all menu items
|
||||
var items []string
|
||||
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(accentColors["selected"])).Bold(true)
|
||||
for i, name := range a.menuItems {
|
||||
prefix := " "
|
||||
if i == a.selectedIdx {
|
||||
prefix := "✭ "
|
||||
label := prefix + name
|
||||
items = append(items, selectedStyle.Render(label))
|
||||
} else {
|
||||
label := prefix + name
|
||||
items = append(items, a.styles.MenuItem.Render(label))
|
||||
}
|
||||
}
|
||||
gap := strings.Repeat(" ", 2)
|
||||
diffRow := strings.Join(items, gap)
|
||||
|
||||
// Adaptive gradient colors
|
||||
gradientColors := adaptiveColors.GetGradientColors()
|
||||
bannerGrad := gradientColors["banner"]
|
||||
leftHex := bannerGrad[0]
|
||||
rightHex := bannerGrad[1]
|
||||
|
||||
title := gradientText("Select option", leftHex, rightHex)
|
||||
box := renderGradientBox(diffRow, 2, leftHex, rightHex)
|
||||
// Gradient banner (line by line)
|
||||
var gb strings.Builder
|
||||
for i, l := range strings.Split(strings.TrimRight(banner, "\n"), "\n") {
|
||||
gb.WriteString(gradientText(l, leftHex, rightHex))
|
||||
if i < len(strings.Split(strings.TrimRight(banner, "\n"), "\n"))-1 {
|
||||
gb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
gradientBanner := gb.String()
|
||||
|
||||
// Compose content with explicit 2-line top/bottom padding
|
||||
content := "\n\n" + gradientBanner + "\n\n\n" + optAC + "\n" + optTM + "\n\n\n" + title + "\n" + box + "\n\n"
|
||||
panel := a.styles.Panel.Render(content)
|
||||
if a.width > 0 && a.height > 0 {
|
||||
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
|
||||
}
|
||||
return a.styles.App.Render(panel)
|
||||
}
|
||||
|
||||
func boolText(s UIStyles, v bool) string {
|
||||
if v {
|
||||
return s.BoolTrue.Render("ON")
|
||||
}
|
||||
return s.BoolFalse.Render("OFF")
|
||||
}
|
||||
|
||||
func (a App) viewGame() string {
|
||||
innerWidth := 58
|
||||
|
||||
boardAndStatus := Render(a.game)
|
||||
|
||||
label := a.currentDiff
|
||||
if a.currentDiff == "Daily" {
|
||||
label = "Daily Seed"
|
||||
}
|
||||
headerText := label + " Mode"
|
||||
// Adaptive colors for headers
|
||||
adaptiveColors := theme.NewAdaptiveColors(a.th)
|
||||
gradientColors := adaptiveColors.GetGradientColors()
|
||||
|
||||
var header string
|
||||
switch a.currentDiff {
|
||||
case "Easy":
|
||||
easyGrad := gradientColors["easy"]
|
||||
header = gradientText(headerText, easyGrad[0], easyGrad[1])
|
||||
case "Normal":
|
||||
normalGrad := gradientColors["normal"]
|
||||
header = gradientText(headerText, normalGrad[0], normalGrad[1])
|
||||
case "Hard":
|
||||
hardGrad := gradientColors["hard"]
|
||||
header = gradientText(headerText, hardGrad[0], hardGrad[1])
|
||||
case "Lunatic":
|
||||
lunaticGrad := gradientColors["lunatic"]
|
||||
header = gradientText(headerText, lunaticGrad[0], lunaticGrad[1])
|
||||
case "Daily":
|
||||
dailyGrad := gradientColors["daily"]
|
||||
header = gradientText(headerText, dailyGrad[0], dailyGrad[1])
|
||||
default:
|
||||
header = lipgloss.NewStyle().Foreground(lipgloss.Color(a.th.Palette.Accent)).Bold(true).Render(headerText)
|
||||
}
|
||||
|
||||
headerCentered := lipgloss.PlaceHorizontal(innerWidth, lipgloss.Center, header)
|
||||
centered := lipgloss.PlaceHorizontal(innerWidth, lipgloss.Center, boardAndStatus)
|
||||
body := "\n" + headerCentered + "\n\n" + centered + "\n"
|
||||
panel := a.styles.Panel.Render(body)
|
||||
if a.width > 0 && a.height > 0 {
|
||||
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
|
||||
}
|
||||
return a.styles.App.Render(panel)
|
||||
}
|
||||
|
||||
// Helpers: gradient text and gradient bordered box
|
||||
func renderGradientBox(content string, padX int, leftHex, rightHex string) string {
|
||||
w := lipgloss.Width(content) + padX*2
|
||||
top := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("╭") + gradientLine("─", w, leftHex, rightHex) + lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("╮")
|
||||
bottom := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("╰") + gradientLine("─", w, leftHex, rightHex) + lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("╯")
|
||||
left := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("│")
|
||||
right := lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("│")
|
||||
middle := left + strings.Repeat(" ", padX) + content + strings.Repeat(" ", padX) + right
|
||||
return strings.Join([]string{top, middle, bottom}, "\n")
|
||||
}
|
||||
|
||||
func gradientLine(ch string, width int, fromHex, toHex string) string {
|
||||
colors := gradientColors(fromHex, toHex, width)
|
||||
var b strings.Builder
|
||||
for i := 0; i < width; i++ {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colors[i])).Render(ch))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func gradientText(text, leftHex, rightHex string) string {
|
||||
colors := gradientColors(leftHex, rightHex, len(text))
|
||||
var b strings.Builder
|
||||
idx := 0
|
||||
for _, ch := range text { // rune-safe
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colors[idx])).Bold(true).Render(string(ch)))
|
||||
idx++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func gradientColors(fromHex, toHex string, steps int) []string {
|
||||
r1, g1, b1 := hexToRGB(fromHex)
|
||||
r2, g2, b2 := hexToRGB(toHex)
|
||||
out := make([]string, steps)
|
||||
for i := 0; i < steps; i++ {
|
||||
if steps == 1 {
|
||||
out[i] = fromHex
|
||||
continue
|
||||
}
|
||||
t := float64(i) / float64(steps-1)
|
||||
r := int(float64(r1) + (float64(r2)-float64(r1))*t)
|
||||
g := int(float64(g1) + (float64(g2)-float64(g1))*t)
|
||||
b := int(float64(b1) + (float64(b2)-float64(b1))*t)
|
||||
out[i] = fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hexToRGB(hex string) (int, int, int) {
|
||||
h := strings.TrimPrefix(hex, "#")
|
||||
if len(h) != 6 {
|
||||
return 255, 255, 255
|
||||
}
|
||||
var r, g, b int
|
||||
fmt.Sscanf(h, "%02x%02x%02x", &r, &g, &b)
|
||||
return r, g, b
|
||||
}
|
||||
|
||||
func (a App) viewAchievements() string {
|
||||
adaptiveColors := theme.NewAdaptiveColors(a.th)
|
||||
gradientColors := adaptiveColors.GetGradientColors()
|
||||
achGrad := gradientColors["banner"]
|
||||
|
||||
title := gradientText("Achievements", achGrad[0], achGrad[1])
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(title + "\n\n")
|
||||
|
||||
unlockedCount := a.achievements.GetUnlockedCount()
|
||||
totalCount := a.achievements.GetTotalCount()
|
||||
progressText := fmt.Sprintf("Unlocked: %d/%d\n\n", unlockedCount, totalCount)
|
||||
content.WriteString(a.styles.Status.Render(progressText))
|
||||
|
||||
achievementOrder := []string{
|
||||
"first_win", "perfectionist", "speed_demon", "no_mistakes",
|
||||
"streak_master", "lunatic_legend", "daily_devotee", "century",
|
||||
}
|
||||
|
||||
for _, id := range achievementOrder {
|
||||
if ach, ok := a.achievements.Achievements[id]; ok {
|
||||
var line string
|
||||
if ach.Unlocked {
|
||||
line = fmt.Sprintf("%s %s - %s",
|
||||
ach.Icon,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#10b981")).Render(ach.Name),
|
||||
ach.Description)
|
||||
} else {
|
||||
progressBar := ""
|
||||
if ach.Target > 1 {
|
||||
progressBar = fmt.Sprintf(" [%d/%d]", ach.Progress, ach.Target)
|
||||
}
|
||||
line = fmt.Sprintf("%s %s - %s%s",
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render("🔒"),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#9ca3af")).Render(ach.Name),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render(ach.Description),
|
||||
progressBar)
|
||||
}
|
||||
content.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
content.WriteString("\n" + a.styles.Status.Render("Press 'm' or Enter to return to menu"))
|
||||
|
||||
panel := a.styles.Panel.Render(content.String())
|
||||
if a.width > 0 && a.height > 0 {
|
||||
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
|
||||
}
|
||||
return a.styles.App.Render(panel)
|
||||
}
|
||||
|
||||
func (a App) viewLeaderboard() string {
|
||||
adaptiveColors := theme.NewAdaptiveColors(a.th)
|
||||
gradientColors := adaptiveColors.GetGradientColors()
|
||||
leaderGrad := gradientColors["banner"]
|
||||
|
||||
title := gradientText("Leaderboard", leaderGrad[0], leaderGrad[1])
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(title + "\n\n")
|
||||
|
||||
difficulties := []string{"Easy", "Normal", "Hard", "Lunatic"}
|
||||
for _, diff := range difficulties {
|
||||
diffColors := adaptiveColors.GetDifficultyColors()
|
||||
diffColor := diffColors[diff]
|
||||
diffHeader := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(diffColor)).Render(diff)
|
||||
content.WriteString(diffHeader + ":\n")
|
||||
|
||||
leaderboard := a.stats.GetLeaderboard(diff, 5)
|
||||
if len(leaderboard) == 0 {
|
||||
content.WriteString(a.styles.Status.Render(" No records yet\n\n"))
|
||||
} else {
|
||||
for i, record := range leaderboard {
|
||||
medal := " "
|
||||
switch i {
|
||||
case 0:
|
||||
medal = "🥇"
|
||||
case 1:
|
||||
medal = "🥈"
|
||||
case 2:
|
||||
medal = "🥉"
|
||||
}
|
||||
timeStr := stats.FormatTime(record.Time)
|
||||
line := fmt.Sprintf("%s %d. %s", medal, i+1, timeStr)
|
||||
if record.HintsUsed > 0 {
|
||||
line += fmt.Sprintf(" (%d hints)", record.HintsUsed)
|
||||
}
|
||||
content.WriteString(a.styles.Status.Render(line + "\n"))
|
||||
}
|
||||
content.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
content.WriteString(a.styles.Status.Render("Press 'm' or Enter to return to menu"))
|
||||
|
||||
panel := a.styles.Panel.Render(content.String())
|
||||
if a.width > 0 && a.height > 0 {
|
||||
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
|
||||
}
|
||||
return a.styles.App.Render(panel)
|
||||
}
|
||||
287
internal/ui/profile.go
Normal file
287
internal/ui/profile.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"termdoku/internal/database"
|
||||
"termdoku/internal/stats"
|
||||
"termdoku/internal/theme"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// viewProfile displays a simple profile landing page with navigation options.
|
||||
func (a App) viewProfile() string {
|
||||
adaptiveColors := theme.NewAdaptiveColors(a.th)
|
||||
gradientColors := adaptiveColors.GetGradientColors()
|
||||
profileGrad := gradientColors["banner"]
|
||||
|
||||
title := gradientText("User Profile", profileGrad[0], profileGrad[1])
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(title + "\n\n")
|
||||
|
||||
content.WriteString(a.renderProfileSummary())
|
||||
content.WriteString("\n\n")
|
||||
|
||||
instructionStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(a.th.Palette.Accent)).
|
||||
Bold(true)
|
||||
|
||||
content.WriteString(instructionStyle.Render("Navigation:") + "\n")
|
||||
content.WriteString(a.styles.Status.Render(" Press 's' to view detailed profile data (Stats, Achievements, Leaderboard)"))
|
||||
content.WriteString("\n")
|
||||
content.WriteString(a.styles.Status.Render(" Press 'd' to view database info"))
|
||||
content.WriteString("\n")
|
||||
content.WriteString(a.styles.Status.Render(" Press 'm' or Enter to return to menu"))
|
||||
|
||||
panel := a.styles.Panel.Render(content.String())
|
||||
if a.width > 0 && a.height > 0 {
|
||||
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
|
||||
}
|
||||
return a.styles.App.Render(panel)
|
||||
}
|
||||
|
||||
// viewProfileSubmenu displays a dropdown menu for selecting profile data type.
|
||||
func (a App) viewProfileSubmenu() string {
|
||||
adaptiveColors := theme.NewAdaptiveColors(a.th)
|
||||
gradientColors := adaptiveColors.GetGradientColors()
|
||||
profileGrad := gradientColors["banner"]
|
||||
|
||||
title := gradientText(">> Select Profile Data", profileGrad[0], profileGrad[1])
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(title + "\n\n")
|
||||
|
||||
var items []string
|
||||
accentColors := adaptiveColors.GetAccentColors()
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(accentColors["selected"])).
|
||||
Bold(true)
|
||||
|
||||
for i, name := range a.profileMenuItems {
|
||||
if i == a.profileSelectedIdx {
|
||||
prefix := "▶ "
|
||||
label := prefix + name
|
||||
items = append(items, selectedStyle.Render(label))
|
||||
} else {
|
||||
prefix := " "
|
||||
label := prefix + name
|
||||
items = append(items, a.styles.MenuItem.Render(label))
|
||||
}
|
||||
}
|
||||
|
||||
menuContent := strings.Join(items, "\n")
|
||||
|
||||
menuStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(profileGrad[0])).
|
||||
Padding(1, 2).
|
||||
MarginTop(1).
|
||||
MarginBottom(1)
|
||||
|
||||
content.WriteString(menuStyle.Render(menuContent))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
content.WriteString(a.styles.Status.Render("Use ↑/↓ or k/j to navigate, Enter to select"))
|
||||
content.WriteString("\n")
|
||||
content.WriteString(a.styles.Status.Render("Press 'm' or Esc to return to profile"))
|
||||
|
||||
panel := a.styles.Panel.Render(content.String())
|
||||
if a.width > 0 && a.height > 0 {
|
||||
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
|
||||
}
|
||||
return a.styles.App.Render(panel)
|
||||
}
|
||||
|
||||
// renderProfileSummary renders the user summary card.
|
||||
func (a App) renderProfileSummary() string {
|
||||
var sb strings.Builder
|
||||
|
||||
totalGames := a.stats.TotalGames
|
||||
completed := a.stats.CompletedGames
|
||||
winRate := 0.0
|
||||
if totalGames > 0 {
|
||||
winRate = float64(completed) / float64(totalGames) * 100
|
||||
}
|
||||
|
||||
rank := a.calculateRank(completed)
|
||||
rankEmoji := a.getRankEmoji(rank)
|
||||
|
||||
summaryStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(a.th.Palette.Accent)).
|
||||
Padding(1, 2).
|
||||
MarginTop(1)
|
||||
|
||||
summaryContent := fmt.Sprintf(
|
||||
"%s Rank: %s"+
|
||||
"\n>> Total Games: %d"+
|
||||
"\n>> Completed: %d"+
|
||||
"\n>> Win Rate: %.1f%%"+
|
||||
"\n>> Current Streak: %d days"+
|
||||
"\n>> Best Streak: %d days",
|
||||
rankEmoji, rank,
|
||||
totalGames,
|
||||
completed,
|
||||
winRate,
|
||||
a.stats.CurrentStreak,
|
||||
a.stats.BestStreak,
|
||||
)
|
||||
|
||||
sb.WriteString(summaryStyle.Render(summaryContent))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// calculateRank determines user rank based on completed games.
|
||||
func (a App) calculateRank(completed int) string {
|
||||
switch {
|
||||
case completed >= 1000:
|
||||
return "Grandmaster"
|
||||
case completed >= 500:
|
||||
return "Master"
|
||||
case completed >= 250:
|
||||
return "Expert"
|
||||
case completed >= 100:
|
||||
return "Advanced"
|
||||
case completed >= 50:
|
||||
return "Intermediate"
|
||||
case completed >= 25:
|
||||
return "Apprentice"
|
||||
case completed >= 10:
|
||||
return "Novice"
|
||||
case completed >= 1:
|
||||
return "Beginner"
|
||||
default:
|
||||
return "Newcomer"
|
||||
}
|
||||
}
|
||||
|
||||
// getRankEmoji returns an emoji for the rank.
|
||||
func (a App) getRankEmoji(rank string) string {
|
||||
switch rank {
|
||||
case "Grandmaster":
|
||||
return "👑"
|
||||
case "Master":
|
||||
return "🎖️"
|
||||
case "Expert":
|
||||
return "🏅"
|
||||
case "Advanced":
|
||||
return "⭐"
|
||||
case "Intermediate":
|
||||
return "🌟"
|
||||
case "Apprentice":
|
||||
return "✨"
|
||||
case "Novice":
|
||||
return "🔰"
|
||||
case "Beginner":
|
||||
return "🌱"
|
||||
default:
|
||||
return "👤"
|
||||
}
|
||||
}
|
||||
|
||||
// viewDatabaseInfo displays detailed database information.
|
||||
func (a App) viewDatabaseInfo() string {
|
||||
adaptiveColors := theme.NewAdaptiveColors(a.th)
|
||||
gradientColors := adaptiveColors.GetGradientColors()
|
||||
dbGrad := gradientColors["banner"]
|
||||
|
||||
title := gradientText("Database Info", dbGrad[0], dbGrad[1])
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(title + "\n\n")
|
||||
|
||||
db, err := database.Open()
|
||||
if err != nil {
|
||||
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error opening database: %v\n", err)))
|
||||
} else {
|
||||
defer db.Close()
|
||||
|
||||
dbStats, err := db.GetStats()
|
||||
if err != nil {
|
||||
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading stats: %v\n", err)))
|
||||
} else {
|
||||
statsStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(a.th.Palette.Accent)).
|
||||
Padding(1, 2)
|
||||
|
||||
statsContent := fmt.Sprintf(
|
||||
"Total Games: %d"+
|
||||
"\n>> Completed Games: %d"+
|
||||
"\n>> Current Streak: %d"+
|
||||
"\n>> Best Streak: %d"+
|
||||
"\n>> Hints Used: %d"+
|
||||
"\n>> Last Played: %s",
|
||||
dbStats.TotalGames,
|
||||
dbStats.CompletedGames,
|
||||
dbStats.CurrentStreak,
|
||||
dbStats.BestStreak,
|
||||
dbStats.HintsUsed,
|
||||
dbStats.LastPlayedDate,
|
||||
)
|
||||
|
||||
content.WriteString(statsStyle.Render(statsContent))
|
||||
}
|
||||
|
||||
content.WriteString("\n\n")
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(a.th.Palette.Accent)).
|
||||
Render("Achievements")
|
||||
content.WriteString(headerStyle + "\n")
|
||||
|
||||
achievements, err := db.GetAchievements()
|
||||
if err != nil {
|
||||
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading achievements: %v\n", err)))
|
||||
} else {
|
||||
unlockedCount := 0
|
||||
for _, ach := range achievements {
|
||||
if ach.Unlocked {
|
||||
unlockedCount++
|
||||
}
|
||||
}
|
||||
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Unlocked: %d/%d\n", unlockedCount, len(achievements))))
|
||||
}
|
||||
|
||||
content.WriteString("\n")
|
||||
headerStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(a.th.Palette.Accent)).
|
||||
Render("Recent Games")
|
||||
content.WriteString(headerStyle + "\n")
|
||||
|
||||
recentGames, err := db.GetRecentGames(5)
|
||||
if err != nil {
|
||||
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading games: %v\n", err)))
|
||||
} else {
|
||||
if len(recentGames) == 0 {
|
||||
content.WriteString(a.styles.Status.Render("No games in database\n"))
|
||||
} else {
|
||||
for _, game := range recentGames {
|
||||
statusIcon := "✅"
|
||||
if !game.Completed {
|
||||
statusIcon = "❌"
|
||||
}
|
||||
timeStr := stats.FormatTime(game.TimeSeconds)
|
||||
line := fmt.Sprintf("%s %s - %s", statusIcon, game.Difficulty, timeStr)
|
||||
if game.IsDaily {
|
||||
line += " 📅"
|
||||
}
|
||||
content.WriteString(a.styles.Status.Render(line + "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content.WriteString("\n" + a.styles.Status.Render("Press 'm' or Enter to return to menu"))
|
||||
|
||||
panel := a.styles.Panel.Render(content.String())
|
||||
if a.width > 0 && a.height > 0 {
|
||||
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
|
||||
}
|
||||
return a.styles.App.Render(panel)
|
||||
}
|
||||
234
internal/ui/renderer.go
Normal file
234
internal/ui/renderer.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"termdoku/internal/game"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func boardString(m Model) string {
|
||||
var b strings.Builder
|
||||
var dup [9][9]bool
|
||||
if m.autoCheck {
|
||||
dup = game.DuplicateMap(m.board.Values, m.cursorRow, m.cursorCol)
|
||||
}
|
||||
var conf [9][9]bool
|
||||
if m.autoCheck {
|
||||
conf = game.ConflictMap(m.board.Values, m.board.Given)
|
||||
}
|
||||
cellWidth := lipgloss.Width(m.styles.Cell.Render("0"))
|
||||
|
||||
buildLine := func(left, mid, right string) string {
|
||||
seg := strings.Repeat("─", cellWidth)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(left)
|
||||
for c := range 9 {
|
||||
sb.WriteString(seg)
|
||||
switch c {
|
||||
case 8:
|
||||
sb.WriteString(right)
|
||||
case 2, 5:
|
||||
sb.WriteString(mid)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
topBorder := m.styles.RowSep.Render(buildLine("╭", "┬", "╮"))
|
||||
midBorder := m.styles.RowSep.Render(buildLine("├", "┼", "┤"))
|
||||
botBorder := m.styles.RowSep.Render(buildLine("╰", "┴", "╯"))
|
||||
|
||||
b.WriteString(topBorder)
|
||||
b.WriteString("\n")
|
||||
|
||||
for r := range 9 {
|
||||
b.WriteString(m.styles.ColSep.Render("│"))
|
||||
for c := range 9 {
|
||||
if c > 0 && c%3 == 0 {
|
||||
b.WriteString(m.styles.ColSep.Render("│"))
|
||||
}
|
||||
cell := m.cellView(r, c, dup[r][c], conf[r][c])
|
||||
b.WriteString(cell)
|
||||
}
|
||||
b.WriteString(m.styles.ColSep.Render("│"))
|
||||
b.WriteString("\n")
|
||||
if r == 2 || r == 5 {
|
||||
b.WriteString(midBorder)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
b.WriteString(botBorder)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func Render(m Model) string {
|
||||
if m.showHelp {
|
||||
return renderHelpOverlay(m)
|
||||
}
|
||||
if m.paused {
|
||||
return renderPauseScreen(m)
|
||||
}
|
||||
|
||||
board := boardString(m)
|
||||
status := lipgloss.PlaceHorizontal(46, lipgloss.Center, m.StatusLine())
|
||||
|
||||
// Win animation
|
||||
if m.showWinAnim && time.Since(m.winAnimStart) < 2*time.Second {
|
||||
winMsg := renderWinAnimation(m)
|
||||
return board + "\n\n" + winMsg + "\n" + status
|
||||
}
|
||||
|
||||
return board + "\n\n\n" + status
|
||||
}
|
||||
|
||||
func (m Model) cellView(r, c int, isDup, isConf bool) string {
|
||||
v := m.board.Values[r][c]
|
||||
str := "·"
|
||||
|
||||
// Show notes if cell is empty and has notes
|
||||
if v == 0 {
|
||||
if notes, ok := m.notes[[2]int{r, c}]; ok && len(notes) > 0 {
|
||||
// Show first note as indicator
|
||||
str = string('₀' + rune(notes[0])) // subscript numbers
|
||||
}
|
||||
} else {
|
||||
str = string('0' + v)
|
||||
}
|
||||
|
||||
style := m.styles.Cell
|
||||
|
||||
if !m.showHelp && !m.paused && !m.completed {
|
||||
inSameRow := r == m.cursorRow
|
||||
inSameCol := c == m.cursorCol
|
||||
inSameBox := (r/3 == m.cursorRow/3) && (c/3 == m.cursorCol/3)
|
||||
|
||||
if !inSameRow && !inSameCol && !inSameBox {
|
||||
dimColor := "#555555"
|
||||
if m.theme.Name == "light" {
|
||||
dimColor = "#d1d5db"
|
||||
}
|
||||
style = style.Foreground(lipgloss.Color(dimColor))
|
||||
} else if inSameRow || inSameCol || inSameBox {
|
||||
highlightColor := "#6b7280"
|
||||
if m.theme.Name == "light" {
|
||||
highlightColor = "#9ca3af"
|
||||
}
|
||||
if r != m.cursorRow || c != m.cursorCol {
|
||||
style = style.Foreground(lipgloss.Color(highlightColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.board.Given[r][c] {
|
||||
style = m.styles.CellFixed
|
||||
}
|
||||
if isDup {
|
||||
style = m.styles.CellDuplicate
|
||||
}
|
||||
if isConf {
|
||||
style = m.styles.CellConflict
|
||||
}
|
||||
if r == m.cursorRow && c == m.cursorCol {
|
||||
style = m.styles.CellSelected
|
||||
}
|
||||
if deadline, ok := m.flashes[[2]int{r, c}]; ok {
|
||||
if time.Now().Before(deadline) {
|
||||
style = style.Bold(true)
|
||||
}
|
||||
}
|
||||
return style.Render(str)
|
||||
}
|
||||
|
||||
func renderPauseScreen(m Model) string {
|
||||
pauseMsg := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#fbbf24")).
|
||||
Render("⏸ PAUSED")
|
||||
|
||||
hint := m.styles.Status.Render("Press 'p' to resume")
|
||||
|
||||
content := pauseMsg + "\n\n" + hint
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#fbbf24")).
|
||||
Padding(2, 4).
|
||||
Render(content)
|
||||
|
||||
return lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, box)
|
||||
}
|
||||
|
||||
func renderHelpOverlay(m Model) string {
|
||||
title := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(m.theme.Palette.Accent)).
|
||||
Render("KEYBOARD SHORTCUTS")
|
||||
|
||||
helpItems := []struct {
|
||||
keys string
|
||||
desc string
|
||||
}{
|
||||
{"↑↓←→ / hjkl", "Move cursor"},
|
||||
{"1-9", "Enter number"},
|
||||
{"0 / Space", "Clear cell"},
|
||||
{"n", "Toggle note mode"},
|
||||
{"u / Ctrl+Z", "Undo"},
|
||||
{"Ctrl+Y / Ctrl+R", "Redo"},
|
||||
{"Ctrl+H", "Get hint"},
|
||||
{"a", "Toggle auto-check"},
|
||||
{"t", "Toggle timer"},
|
||||
{"p", "Pause game"},
|
||||
{"m", "Main menu"},
|
||||
{"?", "Toggle this help"},
|
||||
{"q / Esc", "Quit"},
|
||||
}
|
||||
|
||||
var helpText strings.Builder
|
||||
for _, item := range helpItems {
|
||||
key := lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")).Bold(true).Render(item.keys)
|
||||
desc := m.styles.Status.Render(item.desc)
|
||||
helpText.WriteString(fmt.Sprintf(" %-25s %s\n", key, desc))
|
||||
}
|
||||
|
||||
content := title + "\n\n" + helpText.String() + "\n" +
|
||||
m.styles.Status.Render("Press '?' again to close")
|
||||
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(m.theme.Palette.Accent)).
|
||||
Padding(1, 2).
|
||||
Render(content)
|
||||
|
||||
return lipgloss.Place(80, 30, lipgloss.Center, lipgloss.Center, box)
|
||||
}
|
||||
|
||||
func renderWinAnimation(m Model) string {
|
||||
elapsed := time.Since(m.winAnimStart).Milliseconds()
|
||||
|
||||
confetti := []string{"✨", "🎉", "🎊", "⭐", "💫", "🌟", "✦", "★"}
|
||||
animPhase := int(elapsed / 150)
|
||||
|
||||
var confettiLine strings.Builder
|
||||
for i := 0; i < 20; i++ {
|
||||
if (i+animPhase)%3 == 0 {
|
||||
symbol := confetti[(i+animPhase)%len(confetti)]
|
||||
confettiLine.WriteString(symbol)
|
||||
} else {
|
||||
confettiLine.WriteString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
colors := []string{"#10b981", "#3b82f6", "#8b5cf6", "#f59e0b", "#ef4444"}
|
||||
colorIdx := (animPhase / 2) % len(colors)
|
||||
|
||||
mainMsg := "🏆 PUZZLE COMPLETE! 🏆"
|
||||
styledMsg := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colors[colorIdx])).
|
||||
Render(mainMsg)
|
||||
|
||||
return confettiLine.String() + "\n" + styledMsg + "\n" + confettiLine.String()
|
||||
}
|
||||
78
internal/ui/styles.go
Normal file
78
internal/ui/styles.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"termdoku/internal/theme"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type UIStyles struct {
|
||||
App lipgloss.Style
|
||||
Panel lipgloss.Style
|
||||
Banner lipgloss.Style
|
||||
MenuItem lipgloss.Style
|
||||
MenuItemSelected lipgloss.Style
|
||||
Hint lipgloss.Style
|
||||
|
||||
BoolTrue lipgloss.Style
|
||||
BoolFalse lipgloss.Style
|
||||
|
||||
Board lipgloss.Style
|
||||
RowSep lipgloss.Style
|
||||
ColSep lipgloss.Style
|
||||
Cell lipgloss.Style
|
||||
CellFixed lipgloss.Style
|
||||
CellSelected lipgloss.Style
|
||||
CellDuplicate lipgloss.Style
|
||||
CellConflict lipgloss.Style
|
||||
Status lipgloss.Style
|
||||
StatusError lipgloss.Style
|
||||
|
||||
DiffBox lipgloss.Style
|
||||
}
|
||||
|
||||
func BuildStyles(t theme.Theme) UIStyles {
|
||||
gridColor := lipgloss.Color(t.Palette.GridLine)
|
||||
accent := lipgloss.Color(t.Palette.Accent)
|
||||
|
||||
// Adaptive colors
|
||||
adaptiveColors := theme.NewAdaptiveColors(t)
|
||||
accentColors := adaptiveColors.GetAccentColors()
|
||||
|
||||
gray := lipgloss.Color("#9ca3af")
|
||||
if t.Name == "light" {
|
||||
gray = lipgloss.Color("#6b7280") // darker gray for light theme
|
||||
}
|
||||
|
||||
menuItemColor := lipgloss.Color(t.Palette.Foreground)
|
||||
statusColor := gray
|
||||
if t.Name == "light" {
|
||||
menuItemColor = lipgloss.Color("#000000")
|
||||
statusColor = menuItemColor
|
||||
}
|
||||
|
||||
return UIStyles{
|
||||
App: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Palette.Foreground)),
|
||||
Panel: lipgloss.NewStyle().Padding(0, 4).Margin(1, 4).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(accentColors["panel"])),
|
||||
Banner: lipgloss.NewStyle().Foreground(accent).Bold(true),
|
||||
MenuItem: lipgloss.NewStyle().Foreground(menuItemColor),
|
||||
MenuItemSelected: lipgloss.NewStyle().Foreground(accent).Bold(true),
|
||||
Hint: lipgloss.NewStyle().Foreground(accent),
|
||||
|
||||
BoolTrue: lipgloss.NewStyle().Foreground(lipgloss.Color("#16a34a")).Bold(true),
|
||||
BoolFalse: lipgloss.NewStyle().Foreground(gray),
|
||||
|
||||
Board: lipgloss.NewStyle(),
|
||||
RowSep: lipgloss.NewStyle().Foreground(gridColor),
|
||||
ColSep: lipgloss.NewStyle().Foreground(gridColor),
|
||||
Cell: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellBaseBG)).Foreground(lipgloss.Color(t.Palette.CellBaseFG)).Padding(0, 1),
|
||||
CellFixed: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellFixedBG)).Foreground(lipgloss.Color(t.Palette.CellFixedFG)).Padding(0, 1).Bold(true),
|
||||
CellSelected: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellSelectedBG)).Foreground(lipgloss.Color(t.Palette.CellSelectedFG)).Padding(0, 1).Bold(true),
|
||||
CellDuplicate: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellDuplicateBG)).Padding(0, 1),
|
||||
CellConflict: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellConflictBG)).Padding(0, 1).Bold(true),
|
||||
Status: lipgloss.NewStyle().Foreground(statusColor),
|
||||
StatusError: lipgloss.NewStyle().Foreground(lipgloss.Color(accentColors["error"])).Bold(true),
|
||||
|
||||
DiffBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(1, 4),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user