(Feat): Initial Commit, Termdoku
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.claude
|
||||
|
||||
.dist/
|
||||
.github
|
||||
.vscode
|
||||
.qodo
|
||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright © 2025 <bereckobrian / bereck-work>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
376
Makefile
Normal file
376
Makefile
Normal file
@@ -0,0 +1,376 @@
|
||||
# Termdoku - Makefile for cross-platform builds
|
||||
# Project configuration
|
||||
BINARY_NAME=termdoku
|
||||
VERSION?=1.0.1
|
||||
BUILD_DIR=dist
|
||||
CMD_DIR=cmd/termdoku
|
||||
MAIN_FILE=$(CMD_DIR)/main.go
|
||||
|
||||
GOCMD=go
|
||||
GOBUILD=$(GOCMD) build
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
GOTEST=$(GOCMD) test
|
||||
GOMOD=$(GOCMD) mod
|
||||
GOVET=$(GOCMD) vet
|
||||
GOFMT=$(GOCMD) fmt
|
||||
|
||||
LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION)"
|
||||
|
||||
CURRENT_OS:=$(shell go env GOOS)
|
||||
CURRENT_ARCH:=$(shell go env GOARCH)
|
||||
|
||||
COLOR_RESET=\033[0m
|
||||
COLOR_BOLD=\033[1m
|
||||
COLOR_DIM=\033[2m
|
||||
COLOR_RED=\033[31m
|
||||
COLOR_GREEN=\033[32m
|
||||
COLOR_YELLOW=\033[33m
|
||||
COLOR_BLUE=\033[34m
|
||||
COLOR_MAGENTA=\033[35m
|
||||
COLOR_CYAN=\033[36m
|
||||
COLOR_WHITE=\033[37m
|
||||
|
||||
SYMBOL_CHECK=✓
|
||||
SYMBOL_CROSS=✗
|
||||
SYMBOL_ARROW=→
|
||||
SYMBOL_STAR=★
|
||||
|
||||
.PHONY: all build clean test vet fmt help
|
||||
.PHONY: build-linux build-darwin build-windows build-freebsd
|
||||
.PHONY: build-all build-linux-all build-darwin-all build-windows-all
|
||||
.PHONY: install deps run coverage benchmark lint check watch dev
|
||||
.PHONY: release version info size
|
||||
|
||||
all: clean build
|
||||
|
||||
## help: Display this help message
|
||||
help:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)╔═══════════════════════════════════════════════════════════╗$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)║$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_MAGENTA)$(SYMBOL_STAR) Termdoku - Makefile Targets $(SYMBOL_STAR)$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_CYAN)║$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)╚═══════════════════════════════════════════════════════════╝$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_WHITE)$(SYMBOL_ARROW) Main Targets:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) build$(COLOR_RESET) Build for current platform ($(COLOR_CYAN)$(CURRENT_OS)/$(CURRENT_ARCH)$(COLOR_RESET))"
|
||||
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) build-all$(COLOR_RESET) Build for all supported platforms"
|
||||
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) run$(COLOR_RESET) Run the application"
|
||||
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) dev$(COLOR_RESET) Run in development mode (with auto-rebuild)"
|
||||
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) test$(COLOR_RESET) Run all tests"
|
||||
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) clean$(COLOR_RESET) Remove build artifacts"
|
||||
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) install$(COLOR_RESET) Install to GOPATH/bin"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_WHITE)$(SYMBOL_ARROW) Platform-Specific Builds:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-linux$(COLOR_RESET) Build for Linux amd64"
|
||||
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-linux-all$(COLOR_RESET) Build for all Linux architectures"
|
||||
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-darwin$(COLOR_RESET) Build for macOS amd64"
|
||||
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-darwin-all$(COLOR_RESET) Build for all macOS architectures"
|
||||
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-windows$(COLOR_RESET) Build for Windows amd64"
|
||||
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-windows-all$(COLOR_RESET) Build for all Windows architectures"
|
||||
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-freebsd$(COLOR_RESET) Build for FreeBSD amd64"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_WHITE)$(SYMBOL_ARROW) Development & Quality:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) deps$(COLOR_RESET) Download and tidy dependencies"
|
||||
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) fmt$(COLOR_RESET) Format code with gofmt"
|
||||
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) vet$(COLOR_RESET) Run go vet for code analysis"
|
||||
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) lint$(COLOR_RESET) Run golangci-lint (if installed)"
|
||||
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) check$(COLOR_RESET) Run fmt, vet, and lint"
|
||||
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) coverage$(COLOR_RESET) Run tests with coverage report"
|
||||
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) benchmark$(COLOR_RESET) Run benchmarks"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_WHITE)$(SYMBOL_ARROW) Release & Info:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_MAGENTA)$(SYMBOL_ARROW) release$(COLOR_RESET) Create release builds with checksums"
|
||||
@echo " $(COLOR_MAGENTA)$(SYMBOL_ARROW) version$(COLOR_RESET) Display version information"
|
||||
@echo " $(COLOR_MAGENTA)$(SYMBOL_ARROW) info$(COLOR_RESET) Display project information"
|
||||
@echo " $(COLOR_MAGENTA)$(SYMBOL_ARROW) size$(COLOR_RESET) Show binary sizes"
|
||||
@echo ""
|
||||
@echo "$(COLOR_DIM)Run 'make <target>' to execute a target$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## build: Build binary for current platform
|
||||
build:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)┌───────────────────────────────────────────────────────────┐$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)│$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_GREEN)Building $(BINARY_NAME)$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_CYAN)│$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)└───────────────────────────────────────────────────────────┘$(COLOR_RESET)"
|
||||
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Target Platform:$(COLOR_RESET) $(COLOR_YELLOW)$(CURRENT_OS)/$(CURRENT_ARCH)$(COLOR_RESET)"
|
||||
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Version:$(COLOR_RESET) $(COLOR_YELLOW)$(VERSION)$(COLOR_RESET)"
|
||||
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Output:$(COLOR_RESET) $(COLOR_YELLOW)$(BUILD_DIR)/$(BINARY_NAME)$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@echo "$(COLOR_DIM)Compiling...$(COLOR_RESET)"
|
||||
@$(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_FILE)
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_GREEN)$(SYMBOL_CHECK) Build complete!$(COLOR_RESET)"
|
||||
@ls -lh $(BUILD_DIR)/$(BINARY_NAME) | awk '{print "$(COLOR_DIM)Size: " $$5 "$(COLOR_RESET)"}'
|
||||
@echo ""
|
||||
|
||||
## run: Run the application
|
||||
run:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_MAGENTA)$(SYMBOL_STAR) Running $(BINARY_NAME)...$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@$(GOCMD) run $(MAIN_FILE)
|
||||
|
||||
## clean: Remove build artifacts
|
||||
clean:
|
||||
@echo ""
|
||||
@echo "$(COLOR_YELLOW)$(SYMBOL_ARROW) Cleaning build artifacts...$(COLOR_RESET)"
|
||||
@$(GOCLEAN)
|
||||
@rm -rf $(BUILD_DIR)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Clean complete$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## test: Run tests
|
||||
test:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)$(SYMBOL_ARROW) Running tests...$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@$(GOTEST) -v ./...
|
||||
@echo ""
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Tests complete$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## vet: Run go vet
|
||||
vet:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BLUE)$(SYMBOL_ARROW) Running go vet...$(COLOR_RESET)"
|
||||
@$(GOVET) ./...
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Vet complete$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## fmt: Format code
|
||||
fmt:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BLUE)$(SYMBOL_ARROW) Formatting code...$(COLOR_RESET)"
|
||||
@$(GOFMT) ./...
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Format complete$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BLUE)$(SYMBOL_ARROW) Downloading dependencies...$(COLOR_RESET)"
|
||||
@$(GOMOD) download
|
||||
@$(GOMOD) tidy
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Dependencies updated$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## install: Install binary to GOPATH/bin
|
||||
install:
|
||||
@echo ""
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_ARROW) Installing $(BINARY_NAME)...$(COLOR_RESET)"
|
||||
@$(GOCMD) install $(LDFLAGS) $(CMD_DIR)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Installed to $(COLOR_CYAN)$(shell go env GOPATH)/bin/$(BINARY_NAME)$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Platform-specific builds
|
||||
# ============================================================================
|
||||
|
||||
## build-linux: Build for Linux amd64
|
||||
build-linux:
|
||||
@echo ""
|
||||
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Building for $(COLOR_YELLOW)Linux amd64$(COLOR_RESET)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_FILE)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built: $(COLOR_DIM)$(BUILD_DIR)/$(BINARY_NAME)-linux-amd64$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## build-linux-all: Build for all Linux architectures
|
||||
build-linux-all:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(SYMBOL_ARROW) Building for all Linux architectures...$(COLOR_RESET)"
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@echo " $(COLOR_DIM)Building amd64...$(COLOR_RESET)"
|
||||
@GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_FILE)
|
||||
@echo " $(COLOR_DIM)Building arm64...$(COLOR_RESET)"
|
||||
@GOOS=linux GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 $(MAIN_FILE)
|
||||
@echo " $(COLOR_DIM)Building 386...$(COLOR_RESET)"
|
||||
@GOOS=linux GOARCH=386 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-386 $(MAIN_FILE)
|
||||
@echo " $(COLOR_DIM)Building arm...$(COLOR_RESET)"
|
||||
@GOOS=linux GOARCH=arm $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm $(MAIN_FILE)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built all Linux binaries$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## build-darwin: Build for macOS amd64
|
||||
build-darwin:
|
||||
@echo ""
|
||||
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Building for $(COLOR_YELLOW)macOS amd64$(COLOR_RESET)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_FILE)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built: $(COLOR_DIM)$(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## build-darwin-all: Build for all macOS architectures
|
||||
build-darwin-all:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(SYMBOL_ARROW) Building for all macOS architectures...$(COLOR_RESET)"
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@echo " $(COLOR_DIM)Building amd64...$(COLOR_RESET)"
|
||||
@GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_FILE)
|
||||
@echo " $(COLOR_DIM)Building arm64...$(COLOR_RESET)"
|
||||
@GOOS=darwin GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(MAIN_FILE)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built all macOS binaries$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## build-windows: Build for Windows amd64
|
||||
build-windows:
|
||||
@echo ""
|
||||
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Building for $(COLOR_YELLOW)Windows amd64$(COLOR_RESET)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(MAIN_FILE)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built: $(COLOR_DIM)$(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## build-windows-all: Build for all Windows architectures
|
||||
build-windows-all:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(SYMBOL_ARROW) Building for all Windows architectures...$(COLOR_RESET)"
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@echo " $(COLOR_DIM)Building amd64...$(COLOR_RESET)"
|
||||
@GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(MAIN_FILE)
|
||||
@echo " $(COLOR_DIM)Building arm64...$(COLOR_RESET)"
|
||||
@GOOS=windows GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-arm64.exe $(MAIN_FILE)
|
||||
@echo " $(COLOR_DIM)Building 386...$(COLOR_RESET)"
|
||||
@GOOS=windows GOARCH=386 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-386.exe $(MAIN_FILE)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built all Windows binaries$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## build-freebsd: Build for FreeBSD amd64
|
||||
build-freebsd:
|
||||
@echo ""
|
||||
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Building for $(COLOR_YELLOW)FreeBSD amd64$(COLOR_RESET)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@GOOS=freebsd GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 $(MAIN_FILE)
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built: $(COLOR_DIM)$(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## build-all: Build for all major platforms
|
||||
build-all: build-linux-all build-darwin-all build-windows-all build-freebsd
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_GREEN)╔═══════════════════════════════════════════════════════════╗$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_GREEN)║$(COLOR_RESET) $(COLOR_BOLD)$(SYMBOL_CHECK) All builds complete!$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_GREEN)║$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_GREEN)╚═══════════════════════════════════════════════════════════╝$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Build Artifacts:$(COLOR_RESET)"
|
||||
@ls -lh $(BUILD_DIR) | tail -n +2 | awk '{print " $(COLOR_YELLOW)" $$9 "$(COLOR_RESET) $(COLOR_DIM)(" $$5 ")$(COLOR_RESET)"}'
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Release targets
|
||||
# ============================================================================
|
||||
|
||||
## release: Create release builds with checksums
|
||||
release: clean build-all
|
||||
@echo ""
|
||||
@echo "$(COLOR_MAGENTA)$(SYMBOL_ARROW) Generating checksums...$(COLOR_RESET)"
|
||||
@cd $(BUILD_DIR) && sha256sum * > checksums.txt
|
||||
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Release build complete with checksums$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Checksums:$(COLOR_RESET)"
|
||||
@cat $(BUILD_DIR)/checksums.txt | awk '{print " $(COLOR_DIM)" $$1 "$(COLOR_RESET) $(COLOR_YELLOW)" $$2 "$(COLOR_RESET)"}'
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Development & Quality targets
|
||||
# ============================================================================
|
||||
|
||||
## coverage: Run tests with coverage report
|
||||
coverage:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)$(SYMBOL_ARROW) Running tests with coverage...$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@$(GOTEST) -coverprofile=coverage.out ./...
|
||||
@echo ""
|
||||
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Coverage Summary:$(COLOR_RESET)"
|
||||
@$(GOCMD) tool cover -func=coverage.out | tail -n 1
|
||||
@echo ""
|
||||
@echo "$(COLOR_DIM)Run 'go tool cover -html=coverage.out' to view detailed coverage$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## benchmark: Run benchmarks
|
||||
benchmark:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)$(SYMBOL_ARROW) Running benchmarks...$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@$(GOTEST) -bench=. -benchmem ./...
|
||||
@echo ""
|
||||
|
||||
## lint: Run golangci-lint
|
||||
lint:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BLUE)$(SYMBOL_ARROW) Running linter...$(COLOR_RESET)"
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
golangci-lint run ./...; \
|
||||
echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Lint complete$(COLOR_RESET)"; \
|
||||
else \
|
||||
echo "$(COLOR_YELLOW)$(SYMBOL_CROSS) golangci-lint not installed$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_DIM)Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest$(COLOR_RESET)"; \
|
||||
fi
|
||||
@echo ""
|
||||
|
||||
## check: Run fmt, vet, and lint
|
||||
check: fmt vet lint
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_GREEN)$(SYMBOL_CHECK) All checks complete!$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## dev: Run in development mode (auto-rebuild on changes)
|
||||
dev:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_MAGENTA)$(SYMBOL_STAR) Development Mode$(COLOR_RESET)"
|
||||
@echo "$(COLOR_DIM)Watching for changes... (Ctrl+C to stop)$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@if command -v air >/dev/null 2>&1; then \
|
||||
air; \
|
||||
else \
|
||||
echo "$(COLOR_YELLOW)$(SYMBOL_CROSS) 'air' not installed, running without hot reload$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_DIM)Install with: go install github.com/cosmtrek/air@latest$(COLOR_RESET)"; \
|
||||
echo ""; \
|
||||
$(GOCMD) run $(MAIN_FILE); \
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Info targets
|
||||
# ============================================================================
|
||||
|
||||
## version: Display version information
|
||||
version:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Version Information:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_YELLOW)$(BINARY_NAME)$(COLOR_RESET) version $(COLOR_GREEN)$(VERSION)$(COLOR_RESET)"
|
||||
@echo " Go version: $(COLOR_DIM)$(shell go version)$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## info: Display project information
|
||||
info:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)╔═══════════════════════════════════════════════════════════╗$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)║$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_MAGENTA)Project Information$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_CYAN)║$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)╚═══════════════════════════════════════════════════════════╝$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Project:$(COLOR_RESET) $(COLOR_YELLOW)$(BINARY_NAME)$(COLOR_RESET)"
|
||||
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Version:$(COLOR_RESET) $(COLOR_GREEN)$(VERSION)$(COLOR_RESET)"
|
||||
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Build Dir:$(COLOR_RESET) $(COLOR_DIM)$(BUILD_DIR)$(COLOR_RESET)"
|
||||
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Main File:$(COLOR_RESET) $(COLOR_DIM)$(MAIN_FILE)$(COLOR_RESET)"
|
||||
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Go Version:$(COLOR_RESET) $(COLOR_DIM)$(shell go version | awk '{print $$3}')$(COLOR_RESET)"
|
||||
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Current OS:$(COLOR_RESET) $(COLOR_YELLOW)$(CURRENT_OS)$(COLOR_RESET)"
|
||||
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Current Arch:$(COLOR_RESET) $(COLOR_YELLOW)$(CURRENT_ARCH)$(COLOR_RESET)"
|
||||
@echo ""
|
||||
|
||||
## size: Show binary sizes
|
||||
size:
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Binary Sizes:$(COLOR_RESET)"
|
||||
@if [ -d "$(BUILD_DIR)" ] && [ -n "$$(ls -A $(BUILD_DIR) 2>/dev/null)" ]; then \
|
||||
for file in $(BUILD_DIR)/*; do \
|
||||
if [ -f "$$file" ] && [ "$$(basename $$file)" != "checksums.txt" ]; then \
|
||||
size=$$(ls -lh "$$file" | awk '{print $$5}'); \
|
||||
name=$$(basename "$$file"); \
|
||||
echo " $(COLOR_YELLOW)$$name$(COLOR_RESET) $(COLOR_DIM)- $$size$(COLOR_RESET)"; \
|
||||
fi; \
|
||||
done; \
|
||||
else \
|
||||
echo " $(COLOR_RED)$(SYMBOL_CROSS) No binaries found. Run 'make build' first.$(COLOR_RESET)"; \
|
||||
fi
|
||||
@echo ""
|
||||
254
README.md
Normal file
254
README.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# 🎮 Termdoku
|
||||
|
||||
**A beautiful, feature-rich terminal-based Sudoku game.**
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🎯 Core Gameplay
|
||||
|
||||
- **Multiple Difficulty Levels**: Easy, Normal, Hard, Expert, and Lunatic
|
||||
- **Daily Puzzles**: New puzzle every day with consistent seeds
|
||||
- **Smart Hint System**: Intelligent hints using solving techniques (Naked Singles, Hidden Singles)
|
||||
- **Auto-Check Mode**: Optional real-time validation of moves
|
||||
- **Timer**: Track your solving time
|
||||
- **Undo/Redo**: Full move history support
|
||||
|
||||
### 🧩 Advanced Puzzle Generation
|
||||
|
||||
- **Unique Solution Guarantee**: All puzzles have exactly one solution
|
||||
- **Symmetry Patterns**: Support for rotational, vertical, and horizontal symmetry
|
||||
- **Puzzle Analysis**: Comprehensive difficulty rating and analysis
|
||||
- **Custom Generation**: Create puzzles with custom parameters
|
||||
- **Pattern Detection**: Identifies required solving techniques
|
||||
- **Optimized Solver**: Uses most-constrained-first heuristic for fast solving
|
||||
|
||||
### 📊 Statistics & Progress
|
||||
|
||||
- **Comprehensive Stats**: Track games played, win rate, streaks, and more
|
||||
- **Best Times**: Personal records for each difficulty level
|
||||
- **Leaderboard**: Top 5 fastest times per difficulty
|
||||
- **Daily History**: Track your daily puzzle completions
|
||||
- **Recent Games**: View your last 50 games
|
||||
|
||||
### 🏆 Achievement System
|
||||
|
||||
- **8 Unique Achievements**: Unlock achievements as you play
|
||||
- **Progress Tracking**: See your progress toward each achievement
|
||||
- **Visual Indicators**: Beautiful UI showing locked/unlocked status
|
||||
- **Achievements Include**:
|
||||
- 🏆 First Victory - Complete your first puzzle
|
||||
- ⚡ Speed Demon - Complete an Easy puzzle in under 3 minutes
|
||||
- 💎 Perfectionist - Complete a puzzle without using hints
|
||||
- 🔥 Streak Master - Achieve a 5-day streak
|
||||
- 💯 Century Club - Complete 100 puzzles
|
||||
- 🌙 Lunatic Legend - Complete 10 Lunatic puzzles
|
||||
- 📅 Daily Devotee - Complete 30 daily puzzles
|
||||
- ✨ Flawless - Complete a Hard puzzle without auto-check
|
||||
|
||||
### 🎨 Customization
|
||||
|
||||
- **Theme Support**: Customizable color themes
|
||||
- **Adaptive Colors**: Dynamic color schemes for different UI elements
|
||||
- **Gradient Effects**: Beautiful gradient text and borders
|
||||
- **Configurable Settings**: Auto-check, timer, and more
|
||||
|
||||
### 💾 Data Persistence
|
||||
|
||||
- **SQLite Database**: Robust data storage for games and achievements
|
||||
- **JSON Stats**: Human-readable statistics files
|
||||
- **Save Games**: Resume puzzles later
|
||||
- **Cross-Session**: All data persists between sessions
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.21 or higher
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitlab.com/bereckobrian/termdoku.git
|
||||
cd termdoku
|
||||
|
||||
make build
|
||||
|
||||
# Run
|
||||
./termdoku
|
||||
```
|
||||
|
||||
### Using Go Install
|
||||
|
||||
```bash
|
||||
go install github.com/bereckobrian/termdoku/cmd/termdoku@latest
|
||||
```
|
||||
|
||||
## 🎮 How to Play
|
||||
|
||||
### Controls
|
||||
|
||||
#### Menu Navigation
|
||||
|
||||
- `↑/↓` or `k/j` - Navigate menu items
|
||||
- `←/→` or `h/l` - Navigate menu items (horizontal)
|
||||
- `Enter` - Select menu item
|
||||
- `a` - Toggle auto-check mode
|
||||
- `t` - Toggle timer
|
||||
- `q` - Quit
|
||||
|
||||
#### In-Game
|
||||
|
||||
- `Arrow Keys` or `h/j/k/l` - Move cursor
|
||||
- `1-9` - Enter number
|
||||
- `0` or `Backspace` - Clear cell
|
||||
- `n` - Toggle notes mode
|
||||
- `u` - Undo move
|
||||
- `r` - Redo move
|
||||
- `?` or `h` - Get hint
|
||||
- `c` - Check solution
|
||||
- `s` - Solve puzzle
|
||||
- `m` or `Esc` - Return to menu
|
||||
- `q` - Quit game
|
||||
|
||||
### Game Modes
|
||||
|
||||
1. **Easy** - 38 empty cells, perfect for beginners
|
||||
2. **Normal** - 46 empty cells, balanced difficulty
|
||||
3. **Hard** - 52 empty cells, challenging puzzles
|
||||
4. **Expert** - 56 empty cells, very difficult
|
||||
5. **Lunatic** - 60 empty cells, extreme challenge
|
||||
6. **Daily** - One puzzle per day, same for everyone
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
termdoku/
|
||||
├── cmd/
|
||||
│ └── termdoku/ # Main application entry point
|
||||
├── internal/
|
||||
│ ├── achievements/ # Achievement system
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── database/ # SQLite database layer
|
||||
│ ├── game/ # Game board logic
|
||||
│ ├── generator/ # Puzzle generation engine
|
||||
│ │ ├── api.go # Grid analysis & validation
|
||||
│ │ ├── core.go # Generation algorithms
|
||||
│ │ ├── generator.go # Main generation interface
|
||||
│ │ ├── benchmark.go # Performance benchmarking
|
||||
│ │ └── utils.go # Utility functions
|
||||
│ ├── savegame/ # Save/load game state
|
||||
│ ├── solver/ # Sudoku solver
|
||||
│ ├── stats/ # Statistics tracking
|
||||
│ ├── theme/ # Theme system
|
||||
│ └── ui/ # Terminal UI (Bubble Tea)
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── LICENSE
|
||||
├── Makefile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🔧 Advanced Features
|
||||
|
||||
### Puzzle Generation API
|
||||
|
||||
The generator package provides advanced puzzle generation capabilities:
|
||||
|
||||
```go
|
||||
import "termdoku/internal/generator"
|
||||
|
||||
// Generate a puzzle with specific difficulty
|
||||
puzzle, err := generator.Generate(generator.Hard, "seed")
|
||||
|
||||
// Generate with symmetry
|
||||
puzzle, err := generator.GenerateWithSymmetry(
|
||||
generator.Normal,
|
||||
"seed",
|
||||
generator.SymmetryRotational180,
|
||||
)
|
||||
|
||||
// Generate with analysis
|
||||
result, err := generator.GenerateWithAnalysis(generator.Expert, "seed")
|
||||
fmt.Printf("Difficulty Rating: %d/100\n", generator.RatePuzzle(result.Puzzle))
|
||||
|
||||
// Analyze a puzzle
|
||||
analysis := puzzle.Analyze()
|
||||
fmt.Printf("Symmetry: %s\n", analysis.SymmetryType)
|
||||
fmt.Printf("Techniques: %v\n", analysis.SolvingTechniques)
|
||||
```
|
||||
|
||||
### Puzzle Analysis
|
||||
|
||||
Every puzzle can be analyzed for:
|
||||
|
||||
- **Filled/Empty cells count**
|
||||
- **Candidate distribution** (min, max, average)
|
||||
- **Solution uniqueness**
|
||||
- **Estimated difficulty**
|
||||
- **Required solving techniques**
|
||||
- **Symmetry pattern**
|
||||
- **Complexity score**
|
||||
|
||||
### Benchmarking
|
||||
|
||||
```go
|
||||
import "termdoku/internal/generator"
|
||||
|
||||
// Benchmark generation performance
|
||||
result := generator.BenchmarkGeneration(generator.Hard, 10)
|
||||
fmt.Println(result.String())
|
||||
|
||||
// Compare generation methods
|
||||
standard, symmetric := generator.CompareGenerationMethods(generator.Normal, 10)
|
||||
```
|
||||
|
||||
## 📊 Data Storage
|
||||
|
||||
Termdoku stores data in `~/.termdoku/`:
|
||||
|
||||
- `termdoku.db` - SQLite database (games, achievements, stats)
|
||||
- `stats.json` - Statistics backup (JSON format)
|
||||
- `achievements.json` - Achievements backup (JSON format)
|
||||
- `config.json` - User configuration
|
||||
- `themes/` - Custom theme files
|
||||
|
||||
## 🎨 Themes
|
||||
|
||||
Create custom themes in `~/.termdoku/themes/`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Theme",
|
||||
"palette": {
|
||||
"background": "#1a1b26",
|
||||
"foreground": "#c0caf5",
|
||||
"accent": "#7aa2f7",
|
||||
"error": "#f7768e",
|
||||
"success": "#9ece6a",
|
||||
"warning": "#e0af68"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) - A powerful TUI framework
|
||||
- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Style definitions for nice terminal layouts
|
||||
30
cmd/termdoku/main.go
Normal file
30
cmd/termdoku/main.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"termdoku/internal/config"
|
||||
"termdoku/internal/theme"
|
||||
"termdoku/internal/ui"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Legacy flags kept but ignored when menu is used
|
||||
_ = flag.Bool("daily", false, "Generate daily puzzle")
|
||||
_ = flag.String("difficulty", "normal", "Difficulty: easy|normal|hard|lunatic")
|
||||
flag.Parse()
|
||||
|
||||
// Create example theme on first run (if it doesn't exist)
|
||||
_ = theme.CreateExampleTheme()
|
||||
|
||||
cfg, _ := config.Load()
|
||||
app := ui.NewApp(cfg)
|
||||
if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "ui error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
10
dist/checksums.txt
vendored
Normal file
10
dist/checksums.txt
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
fad9edb554c86030293282ef774dc440c02b93d91753412c1de8b5f5eceb83ea termdoku-darwin-amd64
|
||||
381420f2d134c108f4fdc08fc39bb768b68ddf9043ea272cdb58bff0e1cdb129 termdoku-darwin-arm64
|
||||
d6ce30e6745c2d6bea08312390c4a743220ee5139175437b66fdb1e64f01c55d termdoku-freebsd-amd64
|
||||
24537c007931dd72841544c898a4d1b8a95315bea3802a2fb6a66f7802a8610a termdoku-linux-386
|
||||
127b2b48f6099fac1e6a37decaa567c06523abeb4b884ea581adfa9918a9d35a termdoku-linux-amd64
|
||||
345f50a633f241149b36d4bb4b9667c04ba77ac834c25d27081ebf616262dfd5 termdoku-linux-arm
|
||||
de5fb97cb2268cb49294ec2ab3e655049b809b16ce44b46df284a2a92c293565 termdoku-linux-arm64
|
||||
b288e4acbf0d32e0cbd6c7f017c48a59d7b095d42e95f02d249c18aee2e8b482 termdoku-windows-386.exe
|
||||
93c16a9d36d4f0ce27c4016e9a4d1af9cc3bd61cc16a5c4f936b9d5bac0cdd77 termdoku-windows-amd64.exe
|
||||
5bf5d568e947defaab09277d64f75c5428d093b0b7308aa49e2b038d2edac502 termdoku-windows-arm64.exe
|
||||
BIN
dist/termdoku-darwin-amd64
vendored
Executable file
BIN
dist/termdoku-darwin-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-darwin-arm64
vendored
Executable file
BIN
dist/termdoku-darwin-arm64
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-freebsd-amd64
vendored
Executable file
BIN
dist/termdoku-freebsd-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-linux-386
vendored
Executable file
BIN
dist/termdoku-linux-386
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-linux-amd64
vendored
Executable file
BIN
dist/termdoku-linux-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-linux-arm
vendored
Executable file
BIN
dist/termdoku-linux-arm
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-linux-arm64
vendored
Executable file
BIN
dist/termdoku-linux-arm64
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-windows-386.exe
vendored
Executable file
BIN
dist/termdoku-windows-386.exe
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-windows-amd64.exe
vendored
Executable file
BIN
dist/termdoku-windows-amd64.exe
vendored
Executable file
Binary file not shown.
BIN
dist/termdoku-windows-arm64.exe
vendored
Executable file
BIN
dist/termdoku-windows-arm64.exe
vendored
Executable file
Binary file not shown.
43
go.mod
Normal file
43
go.mod
Normal file
@@ -0,0 +1,43 @@
|
||||
module termdoku
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.6
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.40.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
93
go.sum
Normal file
93
go.sum
Normal file
@@ -0,0 +1,93 @@
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
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),
|
||||
}
|
||||
}
|
||||
11
things-todo.md
Normal file
11
things-todo.md
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
## 📈 Roadmap
|
||||
|
||||
- [ ] Multiplayer mode
|
||||
- [ ] Puzzle import/export
|
||||
- [ ] More solving techniques detection
|
||||
- [ ] Puzzle difficulty predictor using ML
|
||||
- [ ] Mobile app version
|
||||
- [ ] Web version
|
||||
- [ ] Tournament mode
|
||||
- [ ] Custom puzzle editor
|
||||
Reference in New Issue
Block a user