├── backend ├── internal │ ├── database │ │ ├── interface.go │ │ ├── gorm.go │ │ └── postgres.go │ ├── version │ │ └── version.go │ ├── handlers │ │ ├── leaderboard.go │ │ └── auth.go │ ├── cache │ │ └── redis.go │ ├── game │ │ └── engine.go │ ├── config │ │ └── config.go │ └── websocket │ │ ├── hub.go │ │ └── handlers.go ├── cmd │ └── server │ │ ├── static │ │ ├── css │ │ │ ├── dark.css │ │ │ ├── mobile.css │ │ │ └── main.css │ │ └── js │ │ │ ├── theme.js │ │ │ ├── auth.js │ │ │ ├── leaderboard.js │ │ │ ├── websocket.js │ │ │ ├── game.js │ │ │ └── main.js │ │ ├── templates │ │ ├── error.html │ │ ├── login_success.html │ │ └── login.html │ │ └── main.go ├── go.mod ├── pkg │ └── models │ │ ├── game.go │ │ └── gorm_models.go └── migrations │ └── 001_initial_schema.sql ├── .gitignore ├── docker ├── Dockerfile.backend └── docker-compose.yml ├── .env.example ├── scripts ├── build.sh ├── deploy.sh └── dev.sh ├── docs └── admin-api.md ├── nginx ├── docker-compose-nginx.conf ├── README.md └── game2048.conf ├── 自定义OAuth2配置示例.md ├── README.md └── 运行指南.md /backend/internal/database/interface.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import "game2048/pkg/models" 4 | 5 | // Database defines the interface for database operations 6 | type Database interface { 7 | // User operations 8 | CreateUser(user *models.User) error 9 | GetUser(userID string) (*models.User, error) 10 | GetUserByProvider(provider, providerID string) (*models.User, error) 11 | 12 | // Game operations 13 | CreateGame(game *models.GameState) error 14 | UpdateGame(game *models.GameState) error 15 | GetGame(gameID, userID string) (*models.GameState, error) 16 | GetUserActiveGame(userID string) (*models.GameState, error) 17 | 18 | // Leaderboard operations 19 | GetLeaderboard(leaderboardType models.LeaderboardType, limit int) ([]models.LeaderboardEntry, error) 20 | 21 | // Connection management 22 | Close() error 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment files 2 | .env 3 | .env.local 4 | .env.production 5 | 6 | # Go 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | *.test 13 | *.out 14 | go.work 15 | vendor/ 16 | bin/ 17 | dist/ 18 | 19 | # Frontend 20 | node_modules/ 21 | frontend/dist/ 22 | frontend/node_modules/ 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # Bun 29 | .bun/ 30 | bun.lockb 31 | 32 | # Database 33 | *.db 34 | *.sqlite 35 | *.sqlite3 36 | 37 | # IDE 38 | .vscode/ 39 | .idea/ 40 | *.swp 41 | *.swo 42 | *~ 43 | 44 | # OS 45 | .DS_Store 46 | .DS_Store? 47 | ._* 48 | .Spotlight-V100 49 | .Trashes 50 | ehthumbs.db 51 | Thumbs.db 52 | 53 | # Docker 54 | .dockerignore 55 | 56 | # Logs 57 | logs/ 58 | *.log 59 | 60 | # Temporary files 61 | tmp/ 62 | temp/ 63 | .tmp/ 64 | 65 | # Build artifacts 66 | backend/bin/ 67 | 68 | # Test coverage 69 | coverage.out 70 | *.cover 71 | 72 | # Air (Go live reload) 73 | .air.toml 74 | tmp/ 75 | -------------------------------------------------------------------------------- /backend/cmd/server/static/css/dark.css: -------------------------------------------------------------------------------- 1 | /* Dark mode overrides */ 2 | body.dark-mode { 3 | background: #1e1e1e; 4 | color: #f3f3f3; 5 | } 6 | 7 | body.dark-mode .game-title, 8 | body.dark-mode .score-label, 9 | body.dark-mode .score-value, 10 | body.dark-mode .user-name { 11 | color: #f3f3f3; 12 | } 13 | 14 | body.dark-mode .instructions, 15 | body.dark-mode .connection-status { 16 | background: #333; 17 | color: #f3f3f3; 18 | } 19 | 20 | body.dark-mode .score-box, 21 | body.dark-mode .new-game-btn, 22 | body.dark-mode .logout-btn, 23 | body.dark-mode .overlay-btn, 24 | body.dark-mode .theme-toggle-btn { 25 | background: #555; 26 | color: #f3f3f3; 27 | } 28 | 29 | body.dark-mode .new-game-btn:hover, 30 | body.dark-mode .logout-btn:hover, 31 | body.dark-mode .overlay-btn:hover, 32 | body.dark-mode .theme-toggle-btn:hover { 33 | background: #777; 34 | } 35 | 36 | body.dark-mode .leaderboard-entry { 37 | background: #444; 38 | } 39 | -------------------------------------------------------------------------------- /backend/cmd/server/static/js/theme.js: -------------------------------------------------------------------------------- 1 | class ThemeToggle { 2 | constructor() { 3 | this.btn = document.getElementById('theme-toggle'); 4 | if (!this.btn) return; 5 | this.applySaved(); 6 | this.btn.addEventListener('click', () => this.toggle()); 7 | } 8 | 9 | applySaved() { 10 | const saved = localStorage.getItem('theme'); 11 | if (saved === 'dark') { 12 | document.body.classList.add('dark-mode'); 13 | this.btn.textContent = 'Light Mode'; 14 | } else { 15 | this.btn.textContent = 'Dark Mode'; 16 | } 17 | } 18 | 19 | toggle() { 20 | if (document.body.classList.contains('dark-mode')) { 21 | document.body.classList.remove('dark-mode'); 22 | localStorage.setItem('theme', 'light'); 23 | this.btn.textContent = 'Dark Mode'; 24 | } else { 25 | document.body.classList.add('dark-mode'); 26 | localStorage.setItem('theme', 'dark'); 27 | this.btn.textContent = 'Light Mode'; 28 | } 29 | } 30 | } 31 | 32 | document.addEventListener('DOMContentLoaded', () => { 33 | window.themeToggle = new ThemeToggle(); 34 | }); 35 | -------------------------------------------------------------------------------- /docker/Dockerfile.backend: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1.21-alpine AS builder 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache git ca-certificates tzdata 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Copy go mod files 11 | COPY backend/go.mod backend/go.sum ./ 12 | 13 | # Download dependencies 14 | RUN go mod download 15 | 16 | # Copy source code 17 | COPY backend/ ./ 18 | 19 | # Build the application 20 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go 21 | 22 | # Final stage 23 | FROM alpine:latest 24 | 25 | # Install runtime dependencies 26 | RUN apk --no-cache add ca-certificates curl 27 | 28 | # Create non-root user 29 | RUN addgroup -g 1001 -S appgroup && \ 30 | adduser -u 1001 -S appuser -G appgroup 31 | 32 | # Set working directory 33 | WORKDIR /root/ 34 | 35 | # Copy binary from builder stage 36 | COPY --from=builder /app/main . 37 | 38 | # Change ownership to non-root user 39 | RUN chown -R appuser:appgroup /root/ 40 | 41 | # Switch to non-root user 42 | USER appuser 43 | 44 | # Expose port 45 | EXPOSE 6060 46 | 47 | # Health check 48 | HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 49 | CMD curl -f http://localhost:6060/health || exit 1 50 | 51 | # Run the application 52 | CMD ["./main"] 53 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database Configuration 2 | DB_HOST=localhost 3 | DB_PORT=5432 4 | DB_NAME=game2048 5 | DB_USER=postgres 6 | DB_PASSWORD=password 7 | DB_SSL_MODE=disable 8 | 9 | # Redis Configuration (optional, for caching) 10 | REDIS_HOST=localhost 11 | REDIS_PORT=6379 12 | REDIS_PASSWORD= 13 | REDIS_DB=0 14 | 15 | # Server Configuration 16 | SERVER_PORT=6060 17 | SERVER_HOST=0.0.0.0 18 | GIN_MODE=release 19 | JWT_SECRET=your-super-secret-jwt-key-change-this-in-production 20 | 21 | # OAuth2 Configuration 22 | # Provider type: custom, google, github, etc. 23 | OAUTH2_PROVIDER=custom 24 | OAUTH2_CLIENT_ID=your_oauth2_client_id 25 | OAUTH2_CLIENT_SECRET=your_oauth2_client_secret 26 | OAUTH2_REDIRECT_URL=http://localhost:6060/auth/callback 27 | 28 | # Custom OAuth2 Endpoints (required for custom provider) 29 | OAUTH2_AUTH_URL=https://connect.linux.do/oauth2/authorize 30 | OAUTH2_TOKEN_URL=https://connect.linux.do/oauth2/token 31 | OAUTH2_USERINFO_URL=https://connect.linux.do/api/user 32 | OAUTH2_SCOPES=openid,profile,email 33 | 34 | # User Info Field Mappings (customize based on your OAuth2 response) 35 | OAUTH2_USER_ID_FIELD=id 36 | OAUTH2_USER_EMAIL_FIELD=email 37 | OAUTH2_USER_NAME_FIELD=username 38 | OAUTH2_USER_AVATAR_FIELD=avatar_url 39 | 40 | # Game Configuration 41 | VICTORY_TILE=16384 42 | MAX_CONCURRENT_GAMES=1000 43 | GAME_SESSION_TIMEOUT=3600 44 | 45 | # Leaderboard Configuration 46 | LEADERBOARD_CACHE_TTL=300 47 | MAX_LEADERBOARD_ENTRIES=100 48 | 49 | # Development Configuration 50 | DEBUG=false 51 | LOG_LEVEL=info 52 | CORS_ORIGINS=http://localhost:3000,http://localhost:6060 53 | 54 | # Production Configuration 55 | STATIC_FILES_EMBEDDED=true 56 | ENABLE_METRICS=true 57 | ENABLE_HEALTH_CHECK=true 58 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:15-alpine 6 | container_name: game2048_postgres 7 | environment: 8 | POSTGRES_DB: game2048 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: password 11 | POSTGRES_INITDB_ARGS: "--encoding=UTF-8" 12 | ports: 13 | - "127.0.0.1:5432:5432" 14 | volumes: 15 | - postgres_data:/var/lib/postgresql/data 16 | - ../backend/migrations:/docker-entrypoint-initdb.d 17 | networks: 18 | - game2048_network 19 | healthcheck: 20 | test: ["CMD-SHELL", "pg_isready -U postgres"] 21 | interval: 10s 22 | timeout: 5s 23 | retries: 5 24 | 25 | redis: 26 | image: redis:7-alpine 27 | container_name: game2048_redis 28 | ports: 29 | - "127.0.0.1:6379:6379" 30 | volumes: 31 | - redis_data:/data 32 | networks: 33 | - game2048_network 34 | healthcheck: 35 | test: ["CMD", "redis-cli", "ping"] 36 | interval: 10s 37 | timeout: 3s 38 | retries: 5 39 | 40 | backend: 41 | build: 42 | context: .. 43 | dockerfile: docker/Dockerfile.backend 44 | container_name: game2048_backend 45 | # Remove direct port exposure since nginx will handle it 46 | ports: 47 | - "127.0.0.1:6060:6060" 48 | environment: 49 | - DB_HOST=postgres 50 | - DB_PORT=5432 51 | - DB_NAME=game2048 52 | - DB_USER=postgres 53 | - DB_PASSWORD=password 54 | - DB_SSL_MODE=disable 55 | - REDIS_HOST=redis 56 | - REDIS_PORT=6379 57 | - SERVER_PORT=6060 58 | - SERVER_HOST=0.0.0.0 59 | - GIN_MODE=release 60 | - STATIC_FILES_EMBEDDED=true 61 | env_file: 62 | - ../.env 63 | depends_on: 64 | postgres: 65 | condition: service_healthy 66 | redis: 67 | condition: service_healthy 68 | networks: 69 | - game2048_network 70 | restart: unless-stopped 71 | healthcheck: 72 | test: ["CMD", "curl", "-f", "http://localhost:6060/health"] 73 | interval: 30s 74 | timeout: 10s 75 | retries: 3 76 | 77 | volumes: 78 | postgres_data: 79 | driver: local 80 | redis_data: 81 | driver: local 82 | 83 | networks: 84 | game2048_network: 85 | driver: bridge 86 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module game2048 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/gin-contrib/cors v1.4.0 7 | github.com/gin-gonic/gin v1.9.1 8 | github.com/golang-jwt/jwt/v5 v5.2.0 9 | github.com/google/uuid v1.5.0 10 | github.com/gorilla/websocket v1.5.1 11 | github.com/joho/godotenv v1.5.1 12 | github.com/lib/pq v1.10.9 13 | github.com/redis/go-redis/v9 v9.3.0 14 | golang.org/x/oauth2 v0.15.0 15 | gorm.io/driver/postgres v1.5.4 16 | gorm.io/gorm v1.25.5 17 | ) 18 | 19 | require ( 20 | github.com/bytedance/sonic v1.9.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 22 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 23 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 24 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 25 | github.com/gin-contrib/sse v0.1.0 // indirect 26 | github.com/go-playground/locales v0.14.1 // indirect 27 | github.com/go-playground/universal-translator v0.18.1 // indirect 28 | github.com/go-playground/validator/v10 v10.14.0 // indirect 29 | github.com/goccy/go-json v0.10.2 // indirect 30 | github.com/golang/protobuf v1.5.3 // indirect 31 | github.com/jackc/pgpassfile v1.0.0 // indirect 32 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 33 | github.com/jackc/pgx/v5 v5.4.3 // indirect 34 | github.com/jinzhu/inflection v1.0.0 // indirect 35 | github.com/jinzhu/now v1.1.5 // indirect 36 | github.com/json-iterator/go v1.1.12 // indirect 37 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 38 | github.com/leodido/go-urn v1.2.4 // indirect 39 | github.com/mattn/go-isatty v0.0.19 // indirect 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 41 | github.com/modern-go/reflect2 v1.0.2 // indirect 42 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 43 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 44 | github.com/ugorji/go/codec v1.2.11 // indirect 45 | golang.org/x/arch v0.3.0 // indirect 46 | golang.org/x/crypto v0.16.0 // indirect 47 | golang.org/x/net v0.19.0 // indirect 48 | golang.org/x/sys v0.15.0 // indirect 49 | golang.org/x/text v0.14.0 // indirect 50 | google.golang.org/appengine v1.6.7 // indirect 51 | google.golang.org/protobuf v1.31.0 // indirect 52 | gopkg.in/yaml.v3 v3.0.1 // indirect 53 | ) 54 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Build script for 2048 Game 4 | set -e 5 | 6 | echo "🎮 Building 2048 Game..." 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | # Function to print colored output 16 | print_status() { 17 | echo -e "${BLUE}[INFO]${NC} $1" 18 | } 19 | 20 | print_success() { 21 | echo -e "${GREEN}[SUCCESS]${NC} $1" 22 | } 23 | 24 | print_warning() { 25 | echo -e "${YELLOW}[WARNING]${NC} $1" 26 | } 27 | 28 | print_error() { 29 | echo -e "${RED}[ERROR]${NC} $1" 30 | } 31 | 32 | # Check if we're in the right directory 33 | if [ ! -f "backend/go.mod" ]; then 34 | print_error "Please run this script from the project root directory" 35 | exit 1 36 | fi 37 | 38 | # Create necessary directories 39 | print_status "Creating build directories..." 40 | mkdir -p backend/bin 41 | 42 | # Build the application 43 | print_status "Building application..." 44 | cd backend 45 | 46 | # Check Go version 47 | GO_VERSION=$(go version | cut -d' ' -f3) 48 | print_status "Using Go version: $GO_VERSION" 49 | 50 | # Download dependencies 51 | print_status "Downloading Go dependencies..." 52 | go mod download 53 | 54 | # Run tests if they exist 55 | if ls *_test.go 1> /dev/null 2>&1; then 56 | print_status "Running Go tests..." 57 | go test ./... -v 58 | else 59 | print_warning "No Go tests found" 60 | fi 61 | 62 | # Build the binary 63 | print_status "Compiling binary..." 64 | CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o bin/server cmd/server/main.go 65 | 66 | if [ $? -eq 0 ]; then 67 | print_success "Binary built successfully: backend/bin/server" 68 | else 69 | print_error "Failed to build binary" 70 | exit 1 71 | fi 72 | 73 | # Build for current OS as well (for development) 74 | print_status "Building development binary..." 75 | go build -o bin/server-dev cmd/server/main.go 76 | 77 | cd .. 78 | 79 | # Create deployment package 80 | print_status "Creating deployment package..." 81 | mkdir -p dist 82 | tar -czf dist/game2048-$(date +%Y%m%d-%H%M%S).tar.gz \ 83 | backend/bin/server \ 84 | docker/docker-compose.yml \ 85 | docker/Dockerfile.backend \ 86 | .env.example \ 87 | README.md 88 | 89 | print_success "Build completed successfully!" 90 | print_status "Deployment package created in dist/ directory" 91 | print_status "To run the development server: cd backend && ./bin/server-dev" 92 | print_status "To deploy with Docker: ./scripts/deploy.sh" 93 | 94 | echo "" 95 | echo "🚀 Ready to launch your 2048 game!" 96 | -------------------------------------------------------------------------------- /backend/cmd/server/static/js/auth.js: -------------------------------------------------------------------------------- 1 | // Authentication management 2 | class Auth { 3 | constructor() { 4 | this.setupEventListeners(); 5 | } 6 | 7 | setupEventListeners() { 8 | // Handle logout 9 | window.logout = () => { 10 | this.logout(); 11 | }; 12 | } 13 | 14 | async logout() { 15 | try { 16 | // Call logout endpoint 17 | const response = await fetch('/auth/logout', { 18 | method: 'POST', 19 | credentials: 'include' 20 | }); 21 | 22 | if (response.ok) { 23 | // Clear local storage 24 | localStorage.removeItem('auth_token'); 25 | 26 | // Disconnect WebSocket 27 | if (window.gameWS) { 28 | window.gameWS.disconnect(); 29 | } 30 | 31 | // Redirect to login page 32 | window.location.href = '/'; 33 | } else { 34 | console.error('Logout failed'); 35 | this.showError('Failed to logout'); 36 | } 37 | } catch (error) { 38 | console.error('Logout error:', error); 39 | this.showError('Network error during logout'); 40 | } 41 | } 42 | 43 | showError(message) { 44 | // Create or update error notification 45 | let errorDiv = document.getElementById('auth-error-notification'); 46 | if (!errorDiv) { 47 | errorDiv = document.createElement('div'); 48 | errorDiv.id = 'auth-error-notification'; 49 | errorDiv.style.cssText = ` 50 | position: fixed; 51 | top: 20px; 52 | left: 50%; 53 | transform: translateX(-50%); 54 | background: #f44336; 55 | color: white; 56 | padding: 15px 20px; 57 | border-radius: 8px; 58 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 59 | z-index: 1000; 60 | max-width: 400px; 61 | font-size: 14px; 62 | line-height: 1.4; 63 | text-align: center; 64 | `; 65 | document.body.appendChild(errorDiv); 66 | } 67 | 68 | errorDiv.textContent = message; 69 | errorDiv.style.display = 'block'; 70 | 71 | // Auto-hide after 5 seconds 72 | setTimeout(() => { 73 | if (errorDiv) { 74 | errorDiv.style.display = 'none'; 75 | } 76 | }, 5000); 77 | } 78 | } 79 | 80 | // Initialize auth when DOM is loaded 81 | document.addEventListener('DOMContentLoaded', () => { 82 | window.auth = new Auth(); 83 | }); 84 | -------------------------------------------------------------------------------- /backend/internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | // Manager handles static file versioning 14 | type Manager struct { 15 | versions map[string]string 16 | mutex sync.RWMutex 17 | baseDir string 18 | } 19 | 20 | // NewManager creates a new version manager 21 | func NewManager(staticDir string) *Manager { 22 | return &Manager{ 23 | versions: make(map[string]string), 24 | baseDir: staticDir, 25 | } 26 | } 27 | 28 | // GetVersion returns the version string for a static file 29 | func (m *Manager) GetVersion(filePath string) string { 30 | m.mutex.RLock() 31 | version, exists := m.versions[filePath] 32 | m.mutex.RUnlock() 33 | 34 | if exists { 35 | return version 36 | } 37 | 38 | // Generate version if not cached 39 | version = m.generateVersion(filePath) 40 | 41 | m.mutex.Lock() 42 | m.versions[filePath] = version 43 | m.mutex.Unlock() 44 | 45 | return version 46 | } 47 | 48 | // generateVersion creates a version string based on file modification time and content hash 49 | func (m *Manager) generateVersion(filePath string) string { 50 | fullPath := filepath.Join(m.baseDir, filePath) 51 | 52 | // Get file info 53 | info, err := os.Stat(fullPath) 54 | if err != nil { 55 | // If file doesn't exist, use timestamp as fallback 56 | return fmt.Sprintf("v%d", time.Now().Unix()) 57 | } 58 | 59 | // Use modification time as primary version 60 | modTime := info.ModTime().Unix() 61 | 62 | // For additional uniqueness, calculate file hash 63 | hash := m.calculateFileHash(fullPath) 64 | if hash != "" { 65 | return fmt.Sprintf("v%d_%s", modTime, hash[:8]) 66 | } 67 | 68 | return fmt.Sprintf("v%d", modTime) 69 | } 70 | 71 | // calculateFileHash calculates MD5 hash of file content 72 | func (m *Manager) calculateFileHash(filePath string) string { 73 | file, err := os.Open(filePath) 74 | if err != nil { 75 | return "" 76 | } 77 | defer file.Close() 78 | 79 | hash := md5.New() 80 | if _, err := io.Copy(hash, file); err != nil { 81 | return "" 82 | } 83 | 84 | return fmt.Sprintf("%x", hash.Sum(nil)) 85 | } 86 | 87 | // RefreshVersion forces regeneration of version for a specific file 88 | func (m *Manager) RefreshVersion(filePath string) { 89 | m.mutex.Lock() 90 | delete(m.versions, filePath) 91 | m.mutex.Unlock() 92 | } 93 | 94 | // RefreshAll clears all cached versions 95 | func (m *Manager) RefreshAll() { 96 | m.mutex.Lock() 97 | m.versions = make(map[string]string) 98 | m.mutex.Unlock() 99 | } 100 | 101 | // GetVersionedURL returns a URL with version parameter 102 | func (m *Manager) GetVersionedURL(filePath string) string { 103 | version := m.GetVersion(filePath) 104 | return fmt.Sprintf("%s?%s", filePath, version) 105 | } 106 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Deployment script for 2048 Game 4 | set -e 5 | 6 | echo "🚀 Deploying 2048 Game..." 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | print_status() { 16 | echo -e "${BLUE}[INFO]${NC} $1" 17 | } 18 | 19 | print_success() { 20 | echo -e "${GREEN}[SUCCESS]${NC} $1" 21 | } 22 | 23 | print_warning() { 24 | echo -e "${YELLOW}[WARNING]${NC} $1" 25 | } 26 | 27 | print_error() { 28 | echo -e "${RED}[ERROR]${NC} $1" 29 | } 30 | 31 | # Check if Docker is available 32 | if ! command -v docker &> /dev/null; then 33 | print_error "Docker is not installed or not in PATH" 34 | exit 1 35 | fi 36 | 37 | if ! command -v docker-compose &> /dev/null; then 38 | print_error "Docker Compose is not installed or not in PATH" 39 | exit 1 40 | fi 41 | 42 | # Check if .env file exists 43 | if [ ! -f ".env" ]; then 44 | print_warning ".env file not found, copying from .env.example" 45 | cp .env.example .env 46 | print_warning "Please edit .env file with your OAuth2 credentials before running the application" 47 | fi 48 | 49 | # Build the application first 50 | print_status "Building application..." 51 | ./scripts/build.sh 52 | 53 | # Stop existing containers 54 | print_status "Stopping existing containers..." 55 | docker-compose -f docker/docker-compose.yml down 56 | 57 | # Build and start containers 58 | print_status "Building and starting Docker containers..." 59 | docker-compose -f docker/docker-compose.yml up --build -d 60 | 61 | # Wait for services to be ready 62 | print_status "Waiting for services to start..." 63 | sleep 10 64 | 65 | # Check if services are running 66 | print_status "Checking service health..." 67 | 68 | # Check PostgreSQL 69 | if docker-compose -f docker/docker-compose.yml exec -T postgres pg_isready -U postgres > /dev/null 2>&1; then 70 | print_success "PostgreSQL is ready" 71 | else 72 | print_warning "PostgreSQL might not be ready yet" 73 | fi 74 | 75 | # Check Redis 76 | if docker-compose -f docker/docker-compose.yml exec -T redis redis-cli ping > /dev/null 2>&1; then 77 | print_success "Redis is ready" 78 | else 79 | print_warning "Redis might not be ready yet" 80 | fi 81 | 82 | # Check backend 83 | if curl -f http://localhost:6060/health > /dev/null 2>&1; then 84 | print_success "Backend is ready" 85 | else 86 | print_warning "Backend might not be ready yet" 87 | fi 88 | 89 | print_success "Deployment completed!" 90 | print_status "Application is available at: http://localhost:6060" 91 | print_status "To view logs: docker-compose -f docker/docker-compose.yml logs -f" 92 | print_status "To stop: docker-compose -f docker/docker-compose.yml down" 93 | 94 | echo "" 95 | echo "🎮 Your 2048 game is now running!" 96 | echo "📝 Don't forget to configure OAuth2 credentials in .env file" 97 | -------------------------------------------------------------------------------- /scripts/dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Development script for 2048 Game 4 | set -e 5 | 6 | echo "🎮 Starting 2048 Game in Development Mode..." 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | print_status() { 16 | echo -e "${BLUE}[INFO]${NC} $1" 17 | } 18 | 19 | print_success() { 20 | echo -e "${GREEN}[SUCCESS]${NC} $1" 21 | } 22 | 23 | print_warning() { 24 | echo -e "${YELLOW}[WARNING]${NC} $1" 25 | } 26 | 27 | print_error() { 28 | echo -e "${RED}[ERROR]${NC} $1" 29 | } 30 | 31 | # Check if we're in the right directory 32 | if [ ! -f "backend/go.mod" ]; then 33 | print_error "Please run this script from the project root directory" 34 | exit 1 35 | fi 36 | 37 | # Check if .env file exists 38 | if [ ! -f ".env" ]; then 39 | print_warning ".env file not found, copying from .env.example" 40 | cp .env.example .env 41 | print_warning "Please edit .env file with your OAuth2 credentials" 42 | fi 43 | 44 | # Check if Docker is running 45 | if ! docker info > /dev/null 2>&1; then 46 | print_error "Docker is not running. Please start Docker first." 47 | exit 1 48 | fi 49 | 50 | # Start database services 51 | print_status "Starting database services..." 52 | docker-compose -f docker/docker-compose.yml up -d postgres redis 53 | 54 | # Wait for databases to be ready 55 | print_status "Waiting for databases to be ready..." 56 | sleep 5 57 | 58 | # Check database health 59 | print_status "Checking database health..." 60 | for i in {1..30}; do 61 | if docker-compose -f docker/docker-compose.yml exec -T postgres pg_isready -U postgres > /dev/null 2>&1; then 62 | print_success "PostgreSQL is ready" 63 | break 64 | fi 65 | if [ $i -eq 30 ]; then 66 | print_error "PostgreSQL failed to start" 67 | exit 1 68 | fi 69 | sleep 1 70 | done 71 | 72 | for i in {1..30}; do 73 | if docker-compose -f docker/docker-compose.yml exec -T redis redis-cli ping > /dev/null 2>&1; then 74 | print_success "Redis is ready" 75 | break 76 | fi 77 | if [ $i -eq 30 ]; then 78 | print_error "Redis failed to start" 79 | exit 1 80 | fi 81 | sleep 1 82 | done 83 | 84 | # Build the application 85 | print_status "Building application..." 86 | cd backend 87 | go mod tidy 88 | go build -o bin/server-dev cmd/server/main.go 89 | 90 | if [ $? -eq 0 ]; then 91 | print_success "Application built successfully" 92 | else 93 | print_error "Failed to build application" 94 | exit 1 95 | fi 96 | 97 | # Start the server 98 | print_status "Starting server..." 99 | print_status "Server will be available at: http://localhost:6060" 100 | print_status "Press Ctrl+C to stop the server" 101 | 102 | # Set development environment 103 | export GIN_MODE=debug 104 | export STATIC_FILES_EMBEDDED=false 105 | 106 | # Run the server 107 | ./bin/server-dev 108 | -------------------------------------------------------------------------------- /docs/admin-api.md: -------------------------------------------------------------------------------- 1 | # Admin API Documentation 2 | 3 | ## 缓存刷新接口 4 | 5 | ### 手动刷新排行榜缓存 6 | 7 | **接口地址**: `GET /api/admin/refresh-cache` 8 | 9 | **权限要求**: 只有用户ID为 "1" 的管理员用户才能调用此接口 10 | 11 | **功能**: 手动清除排行榜缓存,强制下次请求时从数据库重新加载数据 12 | 13 | #### 请求参数 14 | 15 | | 参数名 | 类型 | 必填 | 说明 | 16 | |--------|------|------|------| 17 | | type | string | 否 | 指定要刷新的排行榜类型 | 18 | 19 | **支持的排行榜类型**: 20 | - `daily` - 日排行榜 21 | - `weekly` - 周排行榜 22 | - `monthly` - 月排行榜 23 | - `all` - 总排行榜 24 | 25 | 如果不提供 `type` 参数,将刷新所有类型的排行榜缓存。 26 | 27 | #### 请求示例 28 | 29 | ```bash 30 | # 刷新所有排行榜缓存 31 | curl -X GET "http://localhost:8080/api/admin/refresh-cache" \ 32 | -H "Cookie: auth_token=your_jwt_token" 33 | 34 | # 刷新特定类型的排行榜缓存 35 | curl -X GET "http://localhost:8080/api/admin/refresh-cache?type=daily" \ 36 | -H "Cookie: auth_token=your_jwt_token" 37 | 38 | # 使用Authorization头 39 | curl -X GET "http://localhost:8080/api/admin/refresh-cache" \ 40 | -H "Authorization: Bearer your_jwt_token" 41 | ``` 42 | 43 | #### 响应示例 44 | 45 | **成功响应** (200 OK): 46 | ```json 47 | { 48 | "message": "Cache refresh completed", 49 | "refreshed_types": ["daily", "weekly", "monthly", "all"] 50 | } 51 | ``` 52 | 53 | **部分成功响应** (200 OK): 54 | ```json 55 | { 56 | "message": "Cache refresh completed with some errors", 57 | "refreshed_types": ["daily", "weekly"], 58 | "errors": [ 59 | "Failed to invalidate monthly cache: connection timeout", 60 | "Failed to invalidate all cache: connection timeout" 61 | ] 62 | } 63 | ``` 64 | 65 | **权限不足** (403 Forbidden): 66 | ```json 67 | { 68 | "error": "Access denied. Admin privileges required." 69 | } 70 | ``` 71 | 72 | **未认证** (401 Unauthorized): 73 | ```json 74 | { 75 | "error": "Authentication required" 76 | } 77 | ``` 78 | 79 | **无效参数** (400 Bad Request): 80 | ```json 81 | { 82 | "error": "Invalid leaderboard type. Must be 'daily', 'weekly', 'monthly', or 'all'" 83 | } 84 | ``` 85 | 86 | **缓存服务不可用** (503 Service Unavailable): 87 | ```json 88 | { 89 | "error": "Cache service not available" 90 | } 91 | ``` 92 | 93 | **完全失败** (500 Internal Server Error): 94 | ```json 95 | { 96 | "message": "Cache refresh completed with some errors", 97 | "refreshed_types": [], 98 | "errors": [ 99 | "Failed to invalidate daily cache: connection failed", 100 | "Failed to invalidate weekly cache: connection failed", 101 | "Failed to invalidate monthly cache: connection failed", 102 | "Failed to invalidate all cache: connection failed" 103 | ] 104 | } 105 | ``` 106 | 107 | #### 使用场景 108 | 109 | 1. **数据更新后**: 当直接在数据库中修改排行榜数据后,需要刷新缓存 110 | 2. **缓存异常**: 当发现排行榜显示的数据不正确时 111 | 3. **定期维护**: 作为定期维护任务的一部分 112 | 4. **测试环境**: 在测试环境中快速更新排行榜数据 113 | 114 | #### 安全注意事项 115 | 116 | - 此接口只能由ID为 "1" 的用户调用 117 | - 需要有效的JWT认证token 118 | - 建议在生产环境中限制此接口的访问频率 119 | - 可以考虑添加IP白名单限制 120 | 121 | #### 技术实现 122 | 123 | - 使用Redis的缓存失效机制 124 | - 支持单个类型或全部类型的缓存刷新 125 | - 提供详细的错误信息和成功状态 126 | - 线程安全的缓存操作 127 | 128 | #### 监控建议 129 | 130 | 建议监控以下指标: 131 | - 接口调用频率 132 | - 缓存刷新成功率 133 | - 缓存刷新耗时 134 | - 错误日志 135 | 136 | #### 扩展功能 137 | 138 | 未来可以考虑添加: 139 | - 定时自动刷新缓存 140 | - 更细粒度的缓存控制 141 | - 缓存预热功能 142 | - 缓存统计信息查询 143 | -------------------------------------------------------------------------------- /nginx/docker-compose-nginx.conf: -------------------------------------------------------------------------------- 1 | # Nginx configuration for Docker Compose setup 2 | # This file should be mounted to /etc/nginx/conf.d/default.conf in nginx container 3 | 4 | upstream game2048_backend { 5 | # Docker Compose service name and port 6 | server game2048_backend:6060; 7 | keepalive 32; 8 | } 9 | 10 | # Rate limiting zones 11 | limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; 12 | limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s; 13 | limit_req_zone $binary_remote_addr zone=ws:10m rate=20r/s; 14 | 15 | server { 16 | listen 80; 17 | server_name _; 18 | 19 | # Security headers 20 | add_header X-Frame-Options "SAMEORIGIN" always; 21 | add_header X-Content-Type-Options "nosniff" always; 22 | add_header X-XSS-Protection "1; mode=block" always; 23 | add_header Referrer-Policy "strict-origin-when-cross-origin" always; 24 | 25 | # Gzip compression 26 | gzip on; 27 | gzip_vary on; 28 | gzip_min_length 1024; 29 | gzip_types 30 | text/plain 31 | text/css 32 | text/xml 33 | text/javascript 34 | application/javascript 35 | application/json 36 | application/xml+rss 37 | application/atom+xml 38 | image/svg+xml; 39 | 40 | # Static file caching 41 | location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { 42 | proxy_pass http://game2048_backend; 43 | proxy_set_header Host $host; 44 | proxy_set_header X-Real-IP $remote_addr; 45 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 46 | proxy_set_header X-Forwarded-Proto $scheme; 47 | 48 | expires 1y; 49 | add_header Cache-Control "public, immutable"; 50 | } 51 | 52 | # WebSocket connections 53 | location /ws { 54 | proxy_pass http://game2048_backend; 55 | proxy_http_version 1.1; 56 | proxy_set_header Upgrade $http_upgrade; 57 | proxy_set_header Connection "upgrade"; 58 | proxy_set_header Host $host; 59 | proxy_set_header X-Real-IP $remote_addr; 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 61 | proxy_set_header X-Forwarded-Proto $scheme; 62 | proxy_read_timeout 86400s; 63 | proxy_send_timeout 86400s; 64 | limit_req zone=ws burst=10 nodelay; 65 | } 66 | 67 | # API endpoints 68 | location /api/ { 69 | proxy_pass http://game2048_backend; 70 | proxy_set_header Host $host; 71 | proxy_set_header X-Real-IP $remote_addr; 72 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 73 | proxy_set_header X-Forwarded-Proto $scheme; 74 | limit_req zone=api burst=20 nodelay; 75 | } 76 | 77 | # Authentication endpoints 78 | location /auth/ { 79 | proxy_pass http://game2048_backend; 80 | proxy_set_header Host $host; 81 | proxy_set_header X-Real-IP $remote_addr; 82 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 83 | proxy_set_header X-Forwarded-Proto $scheme; 84 | limit_req zone=auth burst=5 nodelay; 85 | } 86 | 87 | # Health check 88 | location /health { 89 | proxy_pass http://game2048_backend; 90 | proxy_set_header Host $host; 91 | proxy_set_header X-Real-IP $remote_addr; 92 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 93 | proxy_set_header X-Forwarded-Proto $scheme; 94 | access_log off; 95 | } 96 | 97 | # All other requests 98 | location / { 99 | proxy_pass http://game2048_backend; 100 | proxy_set_header Host $host; 101 | proxy_set_header X-Real-IP $remote_addr; 102 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 103 | proxy_set_header X-Forwarded-Proto $scheme; 104 | proxy_connect_timeout 30s; 105 | proxy_send_timeout 30s; 106 | proxy_read_timeout 30s; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /nginx/README.md: -------------------------------------------------------------------------------- 1 | # Nginx Configuration for 2048 Game 2 | 3 | ## 配置选项 4 | 5 | ### 1. 直接在服务器上使用 Nginx 6 | 7 | 如果你在服务器上直接运行应用(不使用Docker),使用 `game2048.conf`: 8 | 9 | ```bash 10 | # 复制配置文件 11 | sudo cp nginx/game2048.conf /etc/nginx/sites-available/game2048 12 | 13 | # 创建软链接启用站点 14 | sudo ln -s /etc/nginx/sites-available/game2048 /etc/nginx/sites-enabled/ 15 | 16 | # 修改配置文件中的域名 17 | sudo nano /etc/nginx/sites-available/game2048 18 | # 将 your-domain.com 替换为你的实际域名 19 | 20 | # 测试配置 21 | sudo nginx -t 22 | 23 | # 重载 Nginx 24 | sudo systemctl reload nginx 25 | ``` 26 | 27 | **重要配置项:** 28 | - `server_name`: 替换为你的域名 29 | - `upstream game2048_backend`: 确保后端地址正确(默认 127.0.0.1:8080) 30 | 31 | ### 2. 使用 Docker Compose 32 | 33 | 如果使用Docker Compose部署,配置已经包含在 `docker-compose.yml` 中: 34 | 35 | ```bash 36 | # 启动所有服务(包括 Nginx) 37 | docker-compose -f docker/docker-compose.yml up -d 38 | 39 | # 查看服务状态 40 | docker-compose -f docker/docker-compose.yml ps 41 | 42 | # 查看 Nginx 日志 43 | docker-compose -f docker/docker-compose.yml logs nginx 44 | ``` 45 | 46 | ## 主要特性 47 | 48 | ### 🚀 性能优化 49 | - **Gzip 压缩**: 自动压缩 CSS、JS、JSON 等文件 50 | - **静态文件缓存**: CSS/JS 文件缓存 1 年(版本化URL处理缓存失效) 51 | - **Keep-alive 连接**: 减少连接开销 52 | - **缓冲优化**: 合理的代理缓冲设置 53 | 54 | ### 🔒 安全特性 55 | - **速率限制**: 56 | - API 请求: 10 req/s 57 | - 认证请求: 5 req/s 58 | - WebSocket: 20 req/s 59 | - **安全头**: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection 60 | - **敏感文件保护**: 阻止访问 .env、.git 等文件 61 | 62 | ### 🌐 WebSocket 支持 63 | - **完整的 WebSocket 代理**: 支持游戏实时通信 64 | - **长连接**: 24小时超时设置 65 | - **升级头处理**: 正确的 HTTP 升级到 WebSocket 66 | 67 | ### 📊 监控和日志 68 | - **健康检查**: 自动检查后端服务状态 69 | - **访问日志**: 记录所有请求(健康检查除外) 70 | - **错误页面**: 自定义 50x 错误页面 71 | 72 | ## SSL/HTTPS 配置 73 | 74 | ### 使用 Let's Encrypt 75 | 76 | ```bash 77 | # 安装 Certbot 78 | sudo apt install certbot python3-certbot-nginx 79 | 80 | # 获取证书 81 | sudo certbot --nginx -d your-domain.com 82 | 83 | # 自动续期 84 | sudo crontab -e 85 | # 添加: 0 12 * * * /usr/bin/certbot renew --quiet 86 | ``` 87 | 88 | ### 手动 SSL 证书 89 | 90 | 取消注释 `game2048.conf` 中的 HTTPS 部分,并更新证书路径: 91 | 92 | ```nginx 93 | ssl_certificate /path/to/your/certificate.crt; 94 | ssl_certificate_key /path/to/your/private.key; 95 | ``` 96 | 97 | ## 负载均衡 98 | 99 | 如果有多个后端实例,更新 upstream 配置: 100 | 101 | ```nginx 102 | upstream game2048_backend { 103 | server 127.0.0.1:8080; 104 | server 127.0.0.1:8081; 105 | server 127.0.0.1:8082; 106 | 107 | # 负载均衡方法 108 | # least_conn; # 最少连接 109 | # ip_hash; # IP 哈希 110 | 111 | keepalive 32; 112 | } 113 | ``` 114 | 115 | ## 故障排除 116 | 117 | ### 常见问题 118 | 119 | 1. **502 Bad Gateway** 120 | ```bash 121 | # 检查后端服务是否运行 122 | curl http://localhost:8080/health 123 | 124 | # 检查 Nginx 错误日志 125 | sudo tail -f /var/log/nginx/error.log 126 | ``` 127 | 128 | 2. **WebSocket 连接失败** 129 | ```bash 130 | # 检查 WebSocket 升级头 131 | curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" http://localhost/ws 132 | ``` 133 | 134 | 3. **静态文件 404** 135 | ```bash 136 | # 检查文件路径和权限 137 | ls -la /path/to/static/files 138 | ``` 139 | 140 | ### 性能调优 141 | 142 | ```nginx 143 | # 在 http 块中添加 144 | worker_processes auto; 145 | worker_connections 1024; 146 | 147 | # 调整缓冲区大小 148 | proxy_buffer_size 8k; 149 | proxy_buffers 16 8k; 150 | 151 | # 启用 HTTP/2 152 | listen 443 ssl http2; 153 | ``` 154 | 155 | ## 监控建议 156 | 157 | ### 日志分析 158 | ```bash 159 | # 实时查看访问日志 160 | sudo tail -f /var/log/nginx/access.log 161 | 162 | # 分析错误日志 163 | sudo grep "error" /var/log/nginx/error.log 164 | 165 | # 统计请求状态码 166 | awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c 167 | ``` 168 | 169 | ### 性能监控 170 | - 使用 `nginx-module-vts` 模块获取详细统计 171 | - 配置 Prometheus + Grafana 监控 172 | - 设置告警规则监控 5xx 错误率 173 | 174 | ## 备份和恢复 175 | 176 | ```bash 177 | # 备份配置 178 | sudo cp /etc/nginx/sites-available/game2048 /backup/nginx-game2048-$(date +%Y%m%d).conf 179 | 180 | # 恢复配置 181 | sudo cp /backup/nginx-game2048-20231201.conf /etc/nginx/sites-available/game2048 182 | sudo nginx -t && sudo systemctl reload nginx 183 | ``` 184 | -------------------------------------------------------------------------------- /backend/cmd/server/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Error - 2048 Game 7 | 105 | 106 | 107 |
108 |
109 |

Oops! Something went wrong

110 | 111 |
112 | {{.error}} 113 |
114 | 115 |
116 | Go Home 117 | 118 |
119 | 120 |
121 |

If this problem persists, please try:

122 | 128 |
129 |
130 | 131 | 132 | -------------------------------------------------------------------------------- /自定义OAuth2配置示例.md: -------------------------------------------------------------------------------- 1 | # 自定义 OAuth2 配置示例 2 | 3 | ## 🔧 配置说明 4 | 5 | 本项目已经重构为支持任意标准 OAuth2 服务,并使用 GORM 进行数据库操作。 6 | 7 | ### 核心特性 8 | 9 | 1. **灵活的 OAuth2 支持**:支持任何标准 OAuth2 服务 10 | 2. **字段映射配置**:可自定义用户信息字段映射 11 | 3. **嵌套字段支持**:支持点号访问嵌套 JSON 字段 12 | 4. **GORM 数据库层**:使用 GORM 替代原始 SQL,更易维护 13 | 14 | ## 📝 配置步骤 15 | 16 | ### 1. 基础环境配置 17 | 18 | 复制并编辑环境变量文件: 19 | 20 | ```bash 21 | cp .env.example .env 22 | ``` 23 | 24 | ### 2. 自定义 OAuth2 配置 25 | 26 | 编辑 `.env` 文件: 27 | 28 | ```env 29 | # OAuth2 基础配置 30 | OAUTH2_PROVIDER=custom 31 | OAUTH2_CLIENT_ID=your_client_id_here 32 | OAUTH2_CLIENT_SECRET=your_client_secret_here 33 | OAUTH2_REDIRECT_URL=http://localhost:6060/auth/callback 34 | 35 | # OAuth2 端点配置 36 | OAUTH2_AUTH_URL=https://your-oauth-server.com/oauth/authorize 37 | OAUTH2_TOKEN_URL=https://your-oauth-server.com/oauth/token 38 | OAUTH2_USERINFO_URL=https://your-oauth-server.com/api/user 39 | OAUTH2_SCOPES=openid,profile,email 40 | 41 | # 用户信息字段映射 42 | OAUTH2_USER_ID_FIELD=id 43 | OAUTH2_USER_EMAIL_FIELD=email 44 | OAUTH2_USER_NAME_FIELD=name 45 | OAUTH2_USER_AVATAR_FIELD=avatar 46 | ``` 47 | 48 | ### 3. 常见 OAuth2 服务配置示例 49 | 50 | #### 示例 1:标准格式 51 | 如果您的用户信息 API 返回: 52 | ```json 53 | { 54 | "id": "12345", 55 | "email": "user@example.com", 56 | "name": "张三", 57 | "avatar": "https://example.com/avatar.jpg" 58 | } 59 | ``` 60 | 61 | 配置: 62 | ```env 63 | OAUTH2_USER_ID_FIELD=id 64 | OAUTH2_USER_EMAIL_FIELD=email 65 | OAUTH2_USER_NAME_FIELD=name 66 | OAUTH2_USER_AVATAR_FIELD=avatar 67 | ``` 68 | 69 | #### 示例 2:嵌套格式 70 | 如果您的用户信息 API 返回: 71 | ```json 72 | { 73 | "user_id": "12345", 74 | "contact": { 75 | "email": "user@example.com" 76 | }, 77 | "profile": { 78 | "display_name": "张三", 79 | "picture": { 80 | "url": "https://example.com/avatar.jpg" 81 | } 82 | } 83 | } 84 | ``` 85 | 86 | 配置: 87 | ```env 88 | OAUTH2_USER_ID_FIELD=user_id 89 | OAUTH2_USER_EMAIL_FIELD=contact.email 90 | OAUTH2_USER_NAME_FIELD=profile.display_name 91 | OAUTH2_USER_AVATAR_FIELD=profile.picture.url 92 | ``` 93 | 94 | #### 示例 3:企业内部系统 95 | 如果您的企业内部 OAuth2 返回: 96 | ```json 97 | { 98 | "employee_id": "EMP001", 99 | "work_email": "zhang.san@company.com", 100 | "full_name": "张三", 101 | "photo_url": "https://hr.company.com/photos/emp001.jpg" 102 | } 103 | ``` 104 | 105 | 配置: 106 | ```env 107 | OAUTH2_USER_ID_FIELD=employee_id 108 | OAUTH2_USER_EMAIL_FIELD=work_email 109 | OAUTH2_USER_NAME_FIELD=full_name 110 | OAUTH2_USER_AVATAR_FIELD=photo_url 111 | ``` 112 | 113 | ## 🚀 运行项目 114 | 115 | ### 方法 1:Docker 部署(推荐) 116 | 117 | ```bash 118 | # 启动数据库服务 119 | docker-compose -f docker/docker-compose.yml up -d postgres redis 120 | 121 | # 构建并启动后端 122 | docker-compose -f docker/docker-compose.yml up -d backend 123 | ``` 124 | 125 | ### 方法 2:开发模式 126 | 127 | ```bash 128 | # 启动数据库 129 | docker-compose -f docker/docker-compose.yml up -d postgres redis 130 | 131 | # 启动后端 132 | cd backend 133 | go run cmd/server/main.go 134 | ``` 135 | 136 | ## 🔍 测试 OAuth2 配置 137 | 138 | ### 1. 检查配置 139 | 访问 `http://localhost:6060`,点击登录按钮,应该会重定向到您配置的 OAuth2 授权页面。 140 | 141 | ### 2. 调试用户信息映射 142 | 如果登录后出现用户信息错误,可以: 143 | 144 | 1. 查看后端日志,确认用户信息 API 的响应格式 145 | 2. 根据实际响应调整字段映射配置 146 | 3. 重启服务测试 147 | 148 | ### 3. 常见问题排查 149 | 150 | **问题 1:重定向 URI 不匹配** 151 | - 确保 OAuth2 服务中配置的重定向 URI 与 `OAUTH2_REDIRECT_URL` 一致 152 | 153 | **问题 2:用户信息获取失败** 154 | - 检查 `OAUTH2_USERINFO_URL` 是否正确 155 | - 确认访问令牌有足够权限访问用户信息 API 156 | 157 | **问题 3:字段映射错误** 158 | - 使用浏览器开发者工具或 Postman 测试用户信息 API 159 | - 根据实际响应格式调整字段映射 160 | 161 | ## 📊 数据库变更 162 | 163 | 项目已升级使用 GORM: 164 | 165 | ### 优势 166 | - **自动迁移**:启动时自动创建/更新数据库表结构 167 | - **类型安全**:编译时检查数据库操作 168 | - **关系映射**:支持复杂的数据关系 169 | - **查询构建器**:更直观的查询语法 170 | 171 | ### 迁移说明 172 | - 原有的 SQL 迁移文件仍然保留作为参考 173 | - GORM 会自动处理表结构的创建和更新 174 | - 数据库索引和约束通过 GORM 标签定义 175 | 176 | ## 🛠️ 开发指南 177 | 178 | ### 添加新的 OAuth2 提供者 179 | 180 | 如果需要添加特定的 OAuth2 提供者(如钉钉、企业微信等),可以: 181 | 182 | 1. 在 `internal/auth/oauth2.go` 中添加新的提供者实现 183 | 2. 在配置中添加对应的 case 分支 184 | 3. 根据提供者特性实现特殊逻辑 185 | 186 | ### 自定义用户信息处理 187 | 188 | 在 `CustomProvider.GetUserInfo()` 方法中,可以添加: 189 | - 用户信息验证逻辑 190 | - 字段格式转换 191 | - 默认值设置 192 | - 权限检查 193 | 194 | ## 📞 技术支持 195 | 196 | 如果在配置过程中遇到问题: 197 | 198 | 1. 检查 `.env` 文件配置是否正确 199 | 2. 查看后端启动日志 200 | 3. 确认 OAuth2 服务端配置 201 | 4. 测试网络连接和端点可访问性 202 | 203 | 祝您使用愉快!🎮 204 | -------------------------------------------------------------------------------- /backend/cmd/server/templates/login_success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Login Successful - 2048 Game 7 | 95 | 96 | 97 |
98 |
99 |

Login Successful!

100 |

Welcome to 2048! You're now signed in and ready to play.

101 | 102 |
103 | {{if .user.Avatar}} 104 | {{.user.Name}} 105 | {{end}} 106 |
{{.user.Name}}
107 |
{{.user.Email}}
108 |
109 | 110 | 113 | 114 |
115 | Redirecting to game... 116 |
117 |
118 | 119 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /backend/cmd/server/static/js/leaderboard.js: -------------------------------------------------------------------------------- 1 | // Leaderboard management 2 | class Leaderboard { 3 | constructor() { 4 | this.currentType = 'daily'; 5 | this.cache = new Map(); 6 | 7 | // Load initial leaderboard 8 | this.loadLeaderboard('daily'); 9 | } 10 | 11 | loadLeaderboard(type) { 12 | this.currentType = type; 13 | 14 | // Check cache first 15 | if (this.cache.has(type)) { 16 | this.displayLeaderboard(this.cache.get(type)); 17 | return; 18 | } 19 | 20 | // Show loading state 21 | this.showLoading(); 22 | 23 | // Request from server 24 | if (window.gameWS) { 25 | window.gameWS.send('get_leaderboard', { type: type }); 26 | } 27 | } 28 | 29 | updateLeaderboard(data) { 30 | // Cache the data 31 | this.cache.set(data.type, data); 32 | 33 | // Display if it's the current type 34 | if (data.type === this.currentType) { 35 | this.displayLeaderboard(data); 36 | } 37 | } 38 | 39 | displayLeaderboard(data) { 40 | const content = document.getElementById('leaderboard-content'); 41 | if (!content) return; 42 | 43 | if (!data.rankings || data.rankings.length === 0) { 44 | content.innerHTML = ` 45 |
46 |

No scores yet for ${data.type} leaderboard.

47 |

Be the first to set a score!

48 |
49 | `; 50 | return; 51 | } 52 | 53 | const html = ` 54 |
55 | ${data.rankings.map((entry, index) => this.renderLeaderboardEntry(entry, index)).join('')} 56 |
57 | `; 58 | 59 | content.innerHTML = html; 60 | } 61 | 62 | renderLeaderboardEntry(entry, index) { 63 | const isCurrentUser = window.gameData && entry.user_id === window.gameData.user.id; 64 | const rankClass = index < 3 ? `rank-${index + 1}` : ''; 65 | const userClass = isCurrentUser ? 'current-user' : ''; 66 | 67 | return ` 68 |
69 |
70 | ${this.getRankDisplay(entry.rank)} 71 |
72 |
73 | ${entry.user_avatar ? `${entry.user_name}` : ''} 74 | ${this.escapeHtml(entry.user_name)} 75 | ${isCurrentUser ? 'You' : ''} 76 |
77 |
78 | ${entry.score.toLocaleString()} 79 |
80 |
81 | `; 82 | } 83 | 84 | getRankDisplay(rank) { 85 | switch (rank) { 86 | case 1: 87 | return '🥇'; 88 | case 2: 89 | return '🥈'; 90 | case 3: 91 | return '🥉'; 92 | default: 93 | return `#${rank}`; 94 | } 95 | } 96 | 97 | showLoading() { 98 | const content = document.getElementById('leaderboard-content'); 99 | if (content) { 100 | content.innerHTML = ` 101 |
102 |
103 |

Loading leaderboard...

104 |
105 | `; 106 | } 107 | } 108 | 109 | escapeHtml(text) { 110 | const div = document.createElement('div'); 111 | div.textContent = text; 112 | return div.innerHTML; 113 | } 114 | } 115 | 116 | // Global function for tab switching 117 | function switchLeaderboard(type) { 118 | // Update active tab 119 | document.querySelectorAll('.tab-btn').forEach(btn => { 120 | btn.classList.remove('active'); 121 | }); 122 | document.querySelector(`[data-type="${type}"]`).classList.add('active'); 123 | 124 | // Load leaderboard 125 | if (window.leaderboard) { 126 | window.leaderboard.loadLeaderboard(type); 127 | } 128 | } 129 | 130 | // Initialize leaderboard when DOM is loaded 131 | document.addEventListener('DOMContentLoaded', () => { 132 | window.leaderboard = new Leaderboard(); 133 | }); 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2048 Game - H5 Implementation 2 | 3 | ![GitHub Stars](https://img.shields.io/github/stars/linux-do/2048?style=social) ![GitHub Forks](https://img.shields.io/github/forks/linux-do/2048?style=social) ![GitHub Issues](https://img.shields.io/github/issues/linux-do/2048?style=flat-square) ![License](https://img.shields.io/github/license/linux-do/2048?style=flat-square) 4 | 5 | 纯 Vibe coding 项目,演示效果:https://2048.linux.do 6 | 7 | A complete 2048 game implementation with client-server architecture, OAuth2 authentication, and leaderboards. 8 | 9 | ## Features 10 | 11 | - **Victory Condition**: Game ends when two 8192 tiles merge 12 | - **Mobile Compatible**: Responsive design with touch support 13 | - **Dark Mode**: Optional dark theme toggle 14 | - **Real-time Communication**: WebSocket-based client-server communication 15 | - **Authentication**: OAuth2 integration for user login 16 | - **Leaderboards**: Daily, weekly, monthly, and all-time rankings 17 | - **Production Ready**: Docker deployment with embedded static files 18 | 19 | ## Tech Stack 20 | 21 | - **Language**: Go 1.21+ 22 | - **Framework**: Gin 23 | - **Database**: PostgreSQL 24 | - **Cache**: Redis (optional) 25 | - **Authentication**: OAuth2 26 | - **Communication**: WebSocket (gorilla/websocket) 27 | 28 | ### Deployment 29 | - **Containerization**: Docker & Docker Compose 30 | - **Static Files**: Embedded in Go binary 31 | - **Database**: PostgreSQL container 32 | - **Cache**: Redis container 33 | 34 | ## Quick Start 35 | 36 | ### Prerequisites 37 | - Docker & Docker Compose 38 | - Go 1.21+ (for development) 39 | 40 | ### Development Setup 41 | 42 | 1. **Clone and setup**: 43 | ```bash 44 | git clone 45 | cd 2048 46 | cp .env.example .env 47 | # Edit .env with your OAuth2 credentials 48 | ``` 49 | 50 | 2. **Start development environment**: 51 | ```bash 52 | # Use the development script 53 | ./scripts/dev.sh 54 | ``` 55 | 56 | ### Production Deployment 57 | 58 | ```bash 59 | # Build and deploy everything 60 | ./scripts/deploy.sh 61 | ``` 62 | 63 | The game will be available at `http://localhost:6060` 64 | 65 | ## Architecture 66 | 67 | ### Client-Server Communication 68 | - Web interface sends user inputs (swipe/key directions) via WebSocket 69 | - Server processes game logic and returns updated game state 70 | - Server manages scoring, victory conditions, and game persistence 71 | - Real-time leaderboard updates 72 | 73 | ### Authentication Flow 74 | 1. User clicks "Login" → Redirected to OAuth2 provider 75 | 2. OAuth2 callback → Server validates and creates session 76 | 3. WebSocket connection established with authenticated session 77 | 4. Game state tied to user account 78 | 79 | ### Database Schema 80 | - **users**: User profiles from OAuth2 81 | - **games**: Game sessions and final scores 82 | - **leaderboards**: Cached ranking data 83 | - **daily_scores**, **weekly_scores**, **monthly_scores**: Time-based rankings 84 | 85 | ## Development 86 | 87 | ```bash 88 | cd backend 89 | go mod tidy 90 | go run cmd/server/main.go # Development server 91 | go build -o bin/server cmd/server/main.go # Production build 92 | ``` 93 | 94 | ### Database Migrations 95 | ```bash 96 | cd backend 97 | go run migrations/migrate.go up # Apply migrations 98 | go run migrations/migrate.go down # Rollback migrations 99 | ``` 100 | 101 | ## Configuration 102 | 103 | Environment variables (see `.env.example`): 104 | 105 | ```env 106 | # Database 107 | DB_HOST=localhost 108 | DB_PORT=5432 109 | DB_NAME=game2048 110 | DB_USER=postgres 111 | DB_PASSWORD=password 112 | 113 | # Redis (optional) 114 | REDIS_HOST=localhost 115 | REDIS_PORT=6379 116 | 117 | # OAuth2 118 | OAUTH2_PROVIDER=custom # custom, google, github, etc. 119 | OAUTH2_CLIENT_ID=your_oauth2_client_id 120 | OAUTH2_CLIENT_SECRET=your_oauth2_client_secret 121 | OAUTH2_REDIRECT_URL=http://localhost:6060/auth/callback 122 | 123 | # Custom OAuth2 Endpoints (for custom provider) 124 | OAUTH2_AUTH_URL=https://connect.linux.do/oauth2/authorize 125 | OAUTH2_TOKEN_URL=https://connect.linux.do/oauth2/token 126 | OAUTH2_USERINFO_URL=https://connect.linux.do/api/user 127 | 128 | # Server 129 | SERVER_PORT=6060 130 | JWT_SECRET=your-super-secret-jwt-key-change-this-in-production 131 | ``` 132 | 133 | ## API Documentation 134 | 135 | ### WebSocket Events 136 | 137 | **Client → Server**: 138 | - `move`: `{direction: "up|down|left|right"}` 139 | - `new_game`: `{}` 140 | - `get_leaderboard`: `{type: "daily|weekly|monthly|all"}` 141 | 142 | **Server → Client**: 143 | - `game_state`: `{board: [[]], score: number, gameOver: boolean, victory: boolean}` 144 | - `leaderboard`: `{rankings: [{user: string, score: number, rank: number}]}` 145 | - `error`: `{message: string}` 146 | 147 | ## License 148 | 149 | MIT License 150 | 151 | ## Star History 152 | 153 | 154 | 155 | 156 | 157 | Star History Chart 158 | 159 | -------------------------------------------------------------------------------- /backend/pkg/models/game.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // Direction represents the direction of a move 10 | type Direction string 11 | 12 | const ( 13 | DirectionUp Direction = "up" 14 | DirectionDown Direction = "down" 15 | DirectionLeft Direction = "left" 16 | DirectionRight Direction = "right" 17 | ) 18 | 19 | // GameState represents the current state of a 2048 game 20 | type GameState struct { 21 | ID uuid.UUID `json:"id" db:"id"` 22 | UserID string `json:"user_id" db:"user_id"` 23 | Board Board `json:"board" db:"board"` 24 | Score int `json:"score" db:"score"` 25 | GameOver bool `json:"game_over" db:"game_over"` 26 | Victory bool `json:"victory" db:"victory"` 27 | CreatedAt time.Time `json:"created_at" db:"created_at"` 28 | UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 29 | } 30 | 31 | // Board represents a 4x4 game board 32 | type Board [4][4]int 33 | 34 | // User represents a user in the system 35 | type User struct { 36 | ID string `json:"id" db:"id"` 37 | Email string `json:"email" db:"email"` 38 | Name string `json:"name" db:"name"` 39 | Avatar string `json:"avatar" db:"avatar"` 40 | Provider string `json:"provider" db:"provider"` 41 | ProviderID string `json:"provider_id" db:"provider_id"` 42 | CreatedAt time.Time `json:"created_at" db:"created_at"` 43 | UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 44 | } 45 | 46 | // LeaderboardEntry represents an entry in the leaderboard 47 | type LeaderboardEntry struct { 48 | UserID string `json:"user_id" db:"user_id"` 49 | UserName string `json:"user_name" db:"user_name"` 50 | UserAvatar string `json:"user_avatar" db:"user_avatar"` 51 | Score int `json:"score" db:"score"` 52 | Rank int `json:"rank" db:"rank"` 53 | GameID uuid.UUID `json:"game_id" db:"game_id"` 54 | CreatedAt time.Time `json:"created_at" db:"created_at"` 55 | } 56 | 57 | // LeaderboardType represents different types of leaderboards 58 | type LeaderboardType string 59 | 60 | const ( 61 | LeaderboardDaily LeaderboardType = "daily" 62 | LeaderboardWeekly LeaderboardType = "weekly" 63 | LeaderboardMonthly LeaderboardType = "monthly" 64 | LeaderboardAll LeaderboardType = "all" 65 | ) 66 | 67 | // WebSocketMessage represents a message sent over WebSocket 68 | type WebSocketMessage struct { 69 | Type string `json:"type"` 70 | Data interface{} `json:"data"` 71 | } 72 | 73 | // MoveRequest represents a move request from the client 74 | type MoveRequest struct { 75 | Direction Direction `json:"direction"` 76 | } 77 | 78 | // NewGameRequest represents a new game request 79 | type NewGameRequest struct { 80 | // Empty for now, can be extended with game options 81 | } 82 | 83 | // LeaderboardRequest represents a leaderboard request 84 | type LeaderboardRequest struct { 85 | Type LeaderboardType `json:"type"` 86 | } 87 | 88 | // GameResponse represents the response sent to client after a move 89 | type GameResponse struct { 90 | Board Board `json:"board"` 91 | Score int `json:"score"` 92 | GameOver bool `json:"game_over"` 93 | Victory bool `json:"victory"` 94 | Message string `json:"message,omitempty"` 95 | } 96 | 97 | // LeaderboardResponse represents the leaderboard response 98 | type LeaderboardResponse struct { 99 | Type LeaderboardType `json:"type"` 100 | Rankings []LeaderboardEntry `json:"rankings"` 101 | } 102 | 103 | // ErrorResponse represents an error response 104 | type ErrorResponse struct { 105 | Message string `json:"message"` 106 | Code string `json:"code,omitempty"` 107 | } 108 | 109 | // Constants for the game 110 | const ( 111 | BoardSize = 4 112 | VictoryTile = 16384 // Two 8192 tiles merged 113 | InitialTiles = 2 114 | ) 115 | 116 | // NewBoard creates a new empty board 117 | func NewBoard() Board { 118 | return Board{} 119 | } 120 | 121 | // IsEmpty checks if a cell is empty 122 | func (b *Board) IsEmpty(row, col int) bool { 123 | return b[row][col] == 0 124 | } 125 | 126 | // GetEmptyCells returns all empty cell positions 127 | func (b *Board) GetEmptyCells() [][2]int { 128 | var empty [][2]int 129 | for i := 0; i < BoardSize; i++ { 130 | for j := 0; j < BoardSize; j++ { 131 | if b.IsEmpty(i, j) { 132 | empty = append(empty, [2]int{i, j}) 133 | } 134 | } 135 | } 136 | return empty 137 | } 138 | 139 | // SetCell sets a value at the given position 140 | func (b *Board) SetCell(row, col, value int) { 141 | if row >= 0 && row < BoardSize && col >= 0 && col < BoardSize { 142 | b[row][col] = value 143 | } 144 | } 145 | 146 | // GetCell gets the value at the given position 147 | func (b *Board) GetCell(row, col int) int { 148 | if row >= 0 && row < BoardSize && col >= 0 && col < BoardSize { 149 | return b[row][col] 150 | } 151 | return 0 152 | } 153 | 154 | // HasVictoryTile checks if the board contains the victory tile 155 | func (b *Board) HasVictoryTile() bool { 156 | for i := 0; i < BoardSize; i++ { 157 | for j := 0; j < BoardSize; j++ { 158 | if b[i][j] == VictoryTile { 159 | return true 160 | } 161 | } 162 | } 163 | return false 164 | } 165 | 166 | // IsFull checks if the board is full 167 | func (b *Board) IsFull() bool { 168 | return len(b.GetEmptyCells()) == 0 169 | } 170 | 171 | // Copy creates a deep copy of the board 172 | func (b *Board) Copy() Board { 173 | var copy Board 174 | for i := 0; i < BoardSize; i++ { 175 | for j := 0; j < BoardSize; j++ { 176 | copy[i][j] = b[i][j] 177 | } 178 | } 179 | return copy 180 | } 181 | -------------------------------------------------------------------------------- /backend/internal/handlers/leaderboard.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | 9 | "game2048/internal/cache" 10 | "game2048/internal/database" 11 | "game2048/pkg/models" 12 | 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | // LeaderboardHandler handles leaderboard-related requests 17 | type LeaderboardHandler struct { 18 | db database.Database 19 | cache cache.Cache 20 | } 21 | 22 | // NewLeaderboardHandler creates a new leaderboard handler 23 | func NewLeaderboardHandler(db database.Database, redisCache cache.Cache) *LeaderboardHandler { 24 | return &LeaderboardHandler{ 25 | db: db, 26 | cache: redisCache, 27 | } 28 | } 29 | 30 | // GetLeaderboard handles public leaderboard requests 31 | func (h *LeaderboardHandler) GetLeaderboard(c *gin.Context) { 32 | // Get leaderboard type from query parameter 33 | leaderboardType := c.DefaultQuery("type", "daily") 34 | 35 | // Validate leaderboard type 36 | var lbType models.LeaderboardType 37 | switch leaderboardType { 38 | case "daily": 39 | lbType = models.LeaderboardDaily 40 | case "weekly": 41 | lbType = models.LeaderboardWeekly 42 | case "monthly": 43 | lbType = models.LeaderboardMonthly 44 | case "all": 45 | lbType = models.LeaderboardAll 46 | default: 47 | c.JSON(http.StatusBadRequest, gin.H{ 48 | "error": "Invalid leaderboard type. Must be one of: daily, weekly, monthly, all", 49 | }) 50 | return 51 | } 52 | 53 | // Get limit from query parameter (default 100, max 100) 54 | limitStr := c.DefaultQuery("limit", "100") 55 | limit, err := strconv.Atoi(limitStr) 56 | if err != nil || limit < 1 || limit > 100 { 57 | limit = 100 58 | } 59 | 60 | // Try to get from cache first 61 | var entries []models.LeaderboardEntry 62 | 63 | if h.cache != nil { 64 | entries, err = h.cache.GetLeaderboard(lbType) 65 | if err == nil { 66 | // Cache hit, return cached data 67 | response := models.LeaderboardResponse{ 68 | Type: lbType, 69 | Rankings: entries, 70 | } 71 | c.JSON(http.StatusOK, response) 72 | return 73 | } 74 | } 75 | 76 | // Cache miss or no cache, get from database 77 | entries, err = h.db.GetLeaderboard(lbType, limit) 78 | if err != nil { 79 | c.JSON(http.StatusInternalServerError, gin.H{ 80 | "error": "Failed to get leaderboard", 81 | }) 82 | return 83 | } 84 | 85 | // Cache the result if cache is available 86 | if h.cache != nil { 87 | cacheTTL := 30 * time.Second // 30 seconds cache 88 | if err := h.cache.SetLeaderboard(lbType, entries, cacheTTL); err != nil { 89 | // Log error but don't fail the request 90 | // log.Printf("Failed to cache leaderboard: %v", err) 91 | } 92 | } 93 | 94 | // Return response 95 | response := models.LeaderboardResponse{ 96 | Type: lbType, 97 | Rankings: entries, 98 | } 99 | 100 | c.JSON(http.StatusOK, response) 101 | } 102 | 103 | // RefreshCache manually refreshes the leaderboard cache 104 | // Only accessible by user with ID "1" (admin) 105 | func (h *LeaderboardHandler) RefreshCache(c *gin.Context) { 106 | // Get user ID from context (set by auth middleware) 107 | userID, exists := c.Get("user_id") 108 | if !exists { 109 | c.JSON(http.StatusUnauthorized, gin.H{ 110 | "error": "Authentication required", 111 | }) 112 | return 113 | } 114 | 115 | // Check if user is admin (ID = "1") 116 | if userID.(string) != "1" { 117 | c.JSON(http.StatusForbidden, gin.H{ 118 | "error": "Access denied. Admin privileges required.", 119 | }) 120 | return 121 | } 122 | 123 | // Check if cache is available 124 | if h.cache == nil { 125 | c.JSON(http.StatusServiceUnavailable, gin.H{ 126 | "error": "Cache service not available", 127 | }) 128 | return 129 | } 130 | 131 | // Get the type parameter (optional) 132 | typeParam := c.Query("type") 133 | 134 | var refreshedTypes []string 135 | var errors []string 136 | 137 | if typeParam != "" { 138 | // Refresh specific leaderboard type 139 | lbType := models.LeaderboardType(typeParam) 140 | 141 | // Validate leaderboard type 142 | if lbType != models.LeaderboardDaily && lbType != models.LeaderboardWeekly && lbType != models.LeaderboardMonthly && lbType != models.LeaderboardAll { 143 | c.JSON(http.StatusBadRequest, gin.H{ 144 | "error": "Invalid leaderboard type. Must be 'daily', 'weekly', 'monthly', or 'all'", 145 | }) 146 | return 147 | } 148 | 149 | // Invalidate cache for this type 150 | if err := h.cache.InvalidateLeaderboard(lbType); err != nil { 151 | errors = append(errors, fmt.Sprintf("Failed to invalidate %s cache: %v", lbType, err)) 152 | } else { 153 | refreshedTypes = append(refreshedTypes, string(lbType)) 154 | } 155 | } else { 156 | // Refresh all leaderboard types 157 | allTypes := []models.LeaderboardType{ 158 | models.LeaderboardDaily, 159 | models.LeaderboardWeekly, 160 | models.LeaderboardMonthly, 161 | models.LeaderboardAll, 162 | } 163 | 164 | for _, lbType := range allTypes { 165 | if err := h.cache.InvalidateLeaderboard(lbType); err != nil { 166 | errors = append(errors, fmt.Sprintf("Failed to invalidate %s cache: %v", lbType, err)) 167 | } else { 168 | refreshedTypes = append(refreshedTypes, string(lbType)) 169 | } 170 | } 171 | } 172 | 173 | // Prepare response 174 | response := gin.H{ 175 | "message": "Cache refresh completed", 176 | "refreshed_types": refreshedTypes, 177 | } 178 | 179 | if len(errors) > 0 { 180 | response["errors"] = errors 181 | response["message"] = "Cache refresh completed with some errors" 182 | } 183 | 184 | // Return appropriate status code 185 | if len(refreshedTypes) > 0 { 186 | c.JSON(http.StatusOK, response) 187 | } else { 188 | c.JSON(http.StatusInternalServerError, response) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /backend/cmd/server/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{.title}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 63 |
64 | 65 | 189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /backend/internal/handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "game2048/internal/auth" 7 | "game2048/internal/database" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // AuthHandler handles authentication-related requests 13 | type AuthHandler struct { 14 | authService *auth.AuthService 15 | db database.Database 16 | } 17 | 18 | // NewAuthHandler creates a new authentication handler 19 | func NewAuthHandler(authService *auth.AuthService, db database.Database) *AuthHandler { 20 | return &AuthHandler{ 21 | authService: authService, 22 | db: db, 23 | } 24 | } 25 | 26 | // Login initiates the OAuth2 login flow 27 | func (h *AuthHandler) Login(c *gin.Context) { 28 | authURL, err := h.authService.GetAuthURL() 29 | if err != nil { 30 | c.JSON(http.StatusInternalServerError, gin.H{ 31 | "error": "Failed to generate auth URL", 32 | }) 33 | return 34 | } 35 | 36 | c.Redirect(http.StatusTemporaryRedirect, authURL) 37 | } 38 | 39 | // Callback handles the OAuth2 callback 40 | func (h *AuthHandler) Callback(c *gin.Context) { 41 | code := c.Query("code") 42 | state := c.Query("state") 43 | errorParam := c.Query("error") 44 | 45 | // Check for OAuth2 errors 46 | if errorParam != "" { 47 | c.HTML(http.StatusBadRequest, "error.html", gin.H{ 48 | "error": "OAuth2 authentication failed: " + errorParam, 49 | }) 50 | return 51 | } 52 | 53 | if code == "" || state == "" { 54 | c.HTML(http.StatusBadRequest, "error.html", gin.H{ 55 | "error": "Missing required parameters", 56 | }) 57 | return 58 | } 59 | 60 | // Handle the callback 61 | user, token, err := h.authService.HandleCallback(c.Request.Context(), code, state) 62 | if err != nil { 63 | c.HTML(http.StatusInternalServerError, "error.html", gin.H{ 64 | "error": "Authentication failed: " + err.Error(), 65 | }) 66 | return 67 | } 68 | 69 | // Check if user exists in database 70 | existingUser, err := h.db.GetUserByProvider(user.Provider, user.ProviderID) 71 | if err != nil { 72 | // User doesn't exist, create new user 73 | if err := h.db.CreateUser(user); err != nil { 74 | c.HTML(http.StatusInternalServerError, "error.html", gin.H{ 75 | "error": "Failed to create user account", 76 | }) 77 | return 78 | } 79 | } else { 80 | // User exists, update user info but keep the existing ID 81 | user.ID = existingUser.ID 82 | user.CreatedAt = existingUser.CreatedAt 83 | if err := h.db.CreateUser(user); err != nil { 84 | c.HTML(http.StatusInternalServerError, "error.html", gin.H{ 85 | "error": "Failed to update user account", 86 | }) 87 | return 88 | } 89 | } 90 | 91 | // Generate JWT token with the correct user ID (either new or existing) 92 | token, err = h.authService.GenerateJWT(user.ID) 93 | if err != nil { 94 | c.HTML(http.StatusInternalServerError, "error.html", gin.H{ 95 | "error": "Failed to generate authentication token", 96 | }) 97 | return 98 | } 99 | 100 | // Set JWT token as HTTP-only cookie 101 | c.SetCookie( 102 | "auth_token", 103 | token, 104 | 3600*24, // 24 hours 105 | "/", 106 | "", 107 | h.isHTTPS(c), // Secure flag based on HTTPS detection 108 | true, // HTTP-only 109 | ) 110 | 111 | // Redirect to game page 112 | c.HTML(http.StatusOK, "login_success.html", gin.H{ 113 | "user": user, 114 | "token": token, 115 | }) 116 | } 117 | 118 | // Logout handles user logout 119 | func (h *AuthHandler) Logout(c *gin.Context) { 120 | // Clear the auth cookie 121 | c.SetCookie( 122 | "auth_token", 123 | "", 124 | -1, 125 | "/", 126 | "", 127 | h.isHTTPS(c), // Same secure flag as when setting 128 | true, 129 | ) 130 | 131 | c.JSON(http.StatusOK, gin.H{ 132 | "message": "Logged out successfully", 133 | }) 134 | } 135 | 136 | // Me returns the current user information 137 | func (h *AuthHandler) Me(c *gin.Context) { 138 | userID, exists := c.Get("user_id") 139 | if !exists { 140 | c.JSON(http.StatusUnauthorized, gin.H{ 141 | "error": "User not authenticated", 142 | }) 143 | return 144 | } 145 | 146 | user, err := h.db.GetUser(userID.(string)) 147 | if err != nil { 148 | c.JSON(http.StatusNotFound, gin.H{ 149 | "error": "User not found", 150 | }) 151 | return 152 | } 153 | 154 | c.JSON(http.StatusOK, gin.H{ 155 | "user": user, 156 | }) 157 | } 158 | 159 | // AuthMiddleware validates JWT tokens 160 | func (h *AuthHandler) AuthMiddleware() gin.HandlerFunc { 161 | return func(c *gin.Context) { 162 | // Try to get token from cookie first 163 | token, err := c.Cookie("auth_token") 164 | if err != nil { 165 | // Try to get token from Authorization header 166 | authHeader := c.GetHeader("Authorization") 167 | if len(authHeader) > 7 && authHeader[:7] == "Bearer " { 168 | token = authHeader[7:] 169 | } else { 170 | c.JSON(http.StatusUnauthorized, gin.H{ 171 | "error": "Missing authentication token", 172 | }) 173 | c.Abort() 174 | return 175 | } 176 | } 177 | 178 | // Validate token 179 | userID, err := h.authService.ValidateJWT(token) 180 | if err != nil { 181 | c.JSON(http.StatusUnauthorized, gin.H{ 182 | "error": "Invalid authentication token", 183 | }) 184 | c.Abort() 185 | return 186 | } 187 | 188 | // Set user ID in context 189 | c.Set("user_id", userID) 190 | c.Next() 191 | } 192 | } 193 | 194 | // OptionalAuthMiddleware validates JWT tokens but doesn't require them 195 | func (h *AuthHandler) OptionalAuthMiddleware() gin.HandlerFunc { 196 | return func(c *gin.Context) { 197 | // Try to get token from cookie first 198 | token, err := c.Cookie("auth_token") 199 | if err != nil { 200 | // Try to get token from Authorization header 201 | authHeader := c.GetHeader("Authorization") 202 | if len(authHeader) > 7 && authHeader[:7] == "Bearer " { 203 | token = authHeader[7:] 204 | } else { 205 | // No token found, continue without authentication 206 | c.Next() 207 | return 208 | } 209 | } 210 | 211 | // Validate token 212 | userID, err := h.authService.ValidateJWT(token) 213 | if err != nil { 214 | // Invalid token, continue without authentication 215 | c.Next() 216 | return 217 | } 218 | 219 | // Set user ID in context 220 | c.Set("user_id", userID) 221 | c.Next() 222 | } 223 | } 224 | 225 | // isHTTPS determines if the request is using HTTPS 226 | // Checks TLS connection, X-Forwarded-Proto header, and X-Forwarded-Ssl header 227 | func (h *AuthHandler) isHTTPS(c *gin.Context) bool { 228 | return c.Request.TLS != nil || 229 | c.GetHeader("X-Forwarded-Proto") == "https" || 230 | c.GetHeader("X-Forwarded-Ssl") == "on" 231 | } 232 | -------------------------------------------------------------------------------- /运行指南.md: -------------------------------------------------------------------------------- 1 | # 2048 游戏运行指南 2 | 3 | ## 🚀 快速开始 4 | 5 | ### 方法一:使用 Docker(推荐) 6 | 7 | 这是最简单的方式,一键启动所有服务: 8 | 9 | ```bash 10 | # 1. 配置环境变量 11 | cp .env.example .env 12 | # 编辑 .env 文件,填入您的 OAuth2 配置 13 | 14 | # 2. 启动所有服务 15 | docker-compose -f docker/docker-compose.yml up -d 16 | 17 | # 3. 访问游戏 18 | # 打开浏览器访问:http://localhost:6060 19 | ``` 20 | 21 | ### 方法二:开发模式运行 22 | 23 | 适合开发和调试: 24 | 25 | ```bash 26 | # 使用开发脚本(推荐) 27 | ./scripts/dev.sh 28 | 29 | # 或者手动启动: 30 | # 1. 启动数据库服务 31 | docker-compose -f docker/docker-compose.yml up -d postgres redis 32 | 33 | # 2. 配置环境变量 34 | cp .env.example .env 35 | # 编辑 .env 文件 36 | 37 | # 3. 启动后端服务 38 | cd backend 39 | go run cmd/server/main.go 40 | 41 | # 4. 访问游戏 42 | # 打开浏览器访问:http://localhost:6060 43 | ``` 44 | 45 | ## 📋 前置要求 46 | 47 | ### Docker 方式 48 | - Docker 49 | - Docker Compose 50 | 51 | ### 开发方式 52 | - Go 1.21+ 53 | - Docker(用于数据库) 54 | 55 | ## ⚙️ 配置说明 56 | 57 | ### 1. OAuth2 配置 58 | 59 | 编辑 `.env` 文件,配置 OAuth2 登录: 60 | 61 | #### 自定义 OAuth2 服务(推荐) 62 | ```env 63 | OAUTH2_PROVIDER=custom 64 | OAUTH2_CLIENT_ID=your_oauth2_client_id 65 | OAUTH2_CLIENT_SECRET=your_oauth2_client_secret 66 | OAUTH2_REDIRECT_URL=http://localhost:6060/auth/callback 67 | 68 | # 自定义 OAuth2 端点 69 | OAUTH2_AUTH_URL=https://your-oauth-server.com/oauth/authorize 70 | OAUTH2_TOKEN_URL=https://your-oauth-server.com/oauth/token 71 | OAUTH2_USERINFO_URL=https://your-oauth-server.com/api/user 72 | OAUTH2_SCOPES=openid,profile,email 73 | 74 | # 用户信息字段映射(根据您的 OAuth2 响应自定义) 75 | OAUTH2_USER_ID_FIELD=id 76 | OAUTH2_USER_EMAIL_FIELD=email 77 | OAUTH2_USER_NAME_FIELD=name 78 | OAUTH2_USER_AVATAR_FIELD=avatar 79 | ``` 80 | 81 | #### Google OAuth2 82 | ```env 83 | OAUTH2_PROVIDER=google 84 | OAUTH2_CLIENT_ID=your_google_client_id 85 | OAUTH2_CLIENT_SECRET=your_google_client_secret 86 | OAUTH2_REDIRECT_URL=http://localhost:6060/auth/callback 87 | ``` 88 | 89 | #### GitHub OAuth2 90 | ```env 91 | OAUTH2_PROVIDER=github 92 | OAUTH2_CLIENT_ID=your_github_client_id 93 | OAUTH2_CLIENT_SECRET=your_github_client_secret 94 | OAUTH2_REDIRECT_URL=http://localhost:6060/auth/callback 95 | ``` 96 | 97 | ### 2. 获取 OAuth2 凭据 98 | 99 | #### 自定义 OAuth2 服务配置 100 | 101 | **基本要求:** 102 | 您的 OAuth2 服务需要支持标准的 OAuth2 授权码流程,包括: 103 | 104 | 1. **授权端点** (`OAUTH2_AUTH_URL`):用户登录和授权的页面 105 | 2. **令牌端点** (`OAUTH2_TOKEN_URL`):交换授权码获取访问令牌 106 | 3. **用户信息端点** (`OAUTH2_USERINFO_URL`):获取用户信息的 API 107 | 108 | **字段映射配置:** 109 | 根据您的 OAuth2 服务返回的用户信息格式,配置字段映射: 110 | 111 | ```env 112 | # 如果用户信息 API 返回: 113 | # { 114 | # "user_id": "12345", 115 | # "email": "user@example.com", 116 | # "display_name": "张三", 117 | # "profile_picture": "https://example.com/avatar.jpg" 118 | # } 119 | 120 | OAUTH2_USER_ID_FIELD=user_id 121 | OAUTH2_USER_EMAIL_FIELD=email 122 | OAUTH2_USER_NAME_FIELD=display_name 123 | OAUTH2_USER_AVATAR_FIELD=profile_picture 124 | ``` 125 | 126 | **嵌套字段支持:** 127 | 支持使用点号访问嵌套字段: 128 | 129 | ```env 130 | # 如果用户信息 API 返回: 131 | # { 132 | # "id": "12345", 133 | # "profile": { 134 | # "name": "张三", 135 | # "avatar": { 136 | # "url": "https://example.com/avatar.jpg" 137 | # } 138 | # } 139 | # } 140 | 141 | OAUTH2_USER_ID_FIELD=id 142 | OAUTH2_USER_NAME_FIELD=profile.name 143 | OAUTH2_USER_AVATAR_FIELD=profile.avatar.url 144 | ``` 145 | 146 | **OAuth2 服务端配置:** 147 | 在您的 OAuth2 服务中添加重定向 URI: 148 | - 开发环境:`http://localhost:6060/auth/callback` 149 | - 生产环境:`https://yourdomain.com/auth/callback` 150 | 151 | #### Google OAuth2 152 | 1. 访问 [Google Cloud Console](https://console.cloud.google.com/) 153 | 2. 创建新项目或选择现有项目 154 | 3. 启用 Google+ API 155 | 4. 创建 OAuth2 客户端 ID 156 | 5. 添加重定向 URI:`http://localhost:6060/auth/callback` 157 | 158 | #### GitHub OAuth2 159 | 1. 访问 [GitHub Developer Settings](https://github.com/settings/developers) 160 | 2. 创建新的 OAuth App 161 | 3. 设置 Authorization callback URL:`http://localhost:6060/auth/callback` 162 | 163 | ## 🎮 使用说明 164 | 165 | ### 游戏规则 166 | - 使用方向键或滑动手势移动方块 167 | - 相同数字的方块会合并 168 | - 目标是达到 8192 方块获得胜利 169 | - 无法移动时游戏结束 170 | 171 | ### 功能特性 172 | - **实时游戏**:WebSocket 通信,服务器端游戏逻辑 173 | - **用户认证**:OAuth2 登录系统 174 | - **排行榜**:日榜、周榜、月榜、总榜 175 | - **移动适配**:响应式设计,支持触摸操作 176 | - **数据持久化**:游戏进度自动保存 177 | 178 | ## 🛠️ 开发指南 179 | 180 | ### 项目结构 181 | ``` 182 | 2048/ 183 | ├── backend/ # Go 后端服务 184 | │ ├── cmd/server/ # 主程序 185 | │ ├── internal/ # 内部包 186 | │ └── pkg/ # 公共包 187 | ├── docker/ # Docker 配置 188 | ├── scripts/ # 构建和部署脚本 189 | └── docs/ # 文档 190 | ``` 191 | 192 | ### 开发命令 193 | 194 | ```bash 195 | # 构建项目 196 | ./scripts/build.sh 197 | 198 | # 部署项目 199 | ./scripts/deploy.sh 200 | 201 | # 查看日志 202 | docker-compose -f docker/docker-compose.yml logs -f 203 | 204 | # 停止服务 205 | docker-compose -f docker/docker-compose.yml down 206 | 207 | # 重启服务 208 | docker-compose -f docker/docker-compose.yml restart 209 | ``` 210 | 211 | ### 数据库管理 212 | 213 | ```bash 214 | # 连接到 PostgreSQL 215 | docker-compose -f docker/docker-compose.yml exec postgres psql -U postgres -d game2048 216 | 217 | # 查看数据库表 218 | \dt 219 | 220 | # 查看用户数据 221 | SELECT * FROM users; 222 | 223 | # 查看游戏数据 224 | SELECT * FROM games ORDER BY score DESC LIMIT 10; 225 | ``` 226 | 227 | ## 🔧 故障排除 228 | 229 | ### 常见问题 230 | 231 | 1. **端口被占用** 232 | ```bash 233 | # 检查端口占用 234 | lsof -i :6060 235 | # 修改 .env 中的 SERVER_PORT 236 | ``` 237 | 238 | 2. **OAuth2 认证失败** 239 | - 检查 `.env` 文件中的客户端 ID 和密钥 240 | - 确认重定向 URL 配置正确 241 | - 检查 OAuth2 应用的域名设置 242 | 243 | 3. **数据库连接失败** 244 | ```bash 245 | # 检查数据库状态 246 | docker-compose -f docker/docker-compose.yml ps 247 | # 重启数据库 248 | docker-compose -f docker/docker-compose.yml restart postgres 249 | ``` 250 | 251 | 4. **WebSocket 连接失败** 252 | - 检查防火墙设置 253 | - 确认服务器正常运行 254 | - 查看浏览器控制台错误信息 255 | 256 | ### 日志查看 257 | 258 | ```bash 259 | # 查看所有服务日志 260 | docker-compose -f docker/docker-compose.yml logs 261 | 262 | # 查看特定服务日志 263 | docker-compose -f docker/docker-compose.yml logs backend 264 | docker-compose -f docker/docker-compose.yml logs postgres 265 | 266 | # 实时查看日志 267 | docker-compose -f docker/docker-compose.yml logs -f 268 | ``` 269 | 270 | ## 🌐 生产部署 271 | 272 | ### 环境变量配置 273 | ```env 274 | # 生产环境配置 275 | GIN_MODE=release 276 | DEBUG=false 277 | SERVER_HOST=0.0.0.0 278 | DB_SSL_MODE=require 279 | 280 | # 安全配置 281 | JWT_SECRET=your-super-secure-jwt-secret-key 282 | CORS_ORIGINS=https://yourdomain.com 283 | 284 | # OAuth2 生产配置 285 | OAUTH2_REDIRECT_URL=https://yourdomain.com/auth/callback 286 | ``` 287 | 288 | ### HTTPS 配置 289 | 建议使用 Nginx 或 Traefik 作为反向代理,配置 SSL 证书。 290 | 291 | ### 性能优化 292 | - 启用 Redis 缓存 293 | - 配置数据库连接池 294 | - 使用 CDN 加速静态资源 295 | 296 | ## 📞 技术支持 297 | 298 | 如果遇到问题,请检查: 299 | 1. 环境变量配置是否正确 300 | 2. 所有服务是否正常运行 301 | 3. 网络连接是否正常 302 | 4. 浏览器控制台是否有错误信息 303 | 304 | 祝您游戏愉快!🎮 305 | -------------------------------------------------------------------------------- /backend/cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "html/template" 7 | "io/fs" 8 | "log" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | "syscall" 13 | "time" 14 | 15 | "game2048/internal/auth" 16 | "game2048/internal/cache" 17 | "game2048/internal/config" 18 | "game2048/internal/database" 19 | "game2048/internal/game" 20 | "game2048/internal/handlers" 21 | "game2048/internal/version" 22 | "game2048/internal/websocket" 23 | 24 | "github.com/gin-contrib/cors" 25 | "github.com/gin-gonic/gin" 26 | ) 27 | 28 | //go:embed static/* 29 | var staticFiles embed.FS 30 | 31 | //go:embed templates/* 32 | var templateFiles embed.FS 33 | 34 | func main() { 35 | // Load configuration 36 | cfg, err := config.Load() 37 | if err != nil { 38 | log.Fatalf("Failed to load configuration: %v", err) 39 | } 40 | 41 | // Set Gin mode 42 | gin.SetMode(cfg.Server.GinMode) 43 | 44 | // Initialize database with GORM 45 | db, err := database.NewGormDB( 46 | cfg.Database.Host, 47 | cfg.Database.Port, 48 | cfg.Database.User, 49 | cfg.Database.Password, 50 | cfg.Database.Name, 51 | cfg.Database.SSLMode, 52 | ) 53 | if err != nil { 54 | log.Fatalf("Failed to connect to database: %v", err) 55 | } 56 | defer db.Close() 57 | 58 | // Initialize Redis cache (optional) 59 | var redisCache cache.Cache 60 | redisCache, err = cache.NewRedisCache(cfg) 61 | if err != nil { 62 | log.Printf("Failed to connect to Redis, continuing without cache: %v", err) 63 | redisCache = nil 64 | } 65 | if redisCache != nil { 66 | defer redisCache.Close() 67 | log.Println("Redis cache initialized successfully") 68 | } 69 | 70 | // Initialize auth service 71 | authService, err := auth.NewAuthService(cfg, redisCache) 72 | if err != nil { 73 | log.Fatalf("Failed to initialize auth service: %v", err) 74 | } 75 | 76 | // Initialize game engine 77 | gameEngine := game.NewEngine() 78 | 79 | // Initialize WebSocket hub 80 | hub := websocket.NewHub(gameEngine, db, authService, redisCache) 81 | go hub.Run() 82 | 83 | // Initialize version manager for static files 84 | versionManager := version.NewManager("cmd/server/static") 85 | 86 | // Initialize handlers 87 | authHandler := handlers.NewAuthHandler(authService, db) 88 | leaderboardHandler := handlers.NewLeaderboardHandler(db, redisCache) 89 | 90 | // Create Gin router 91 | router := gin.Default() 92 | 93 | // Configure CORS 94 | corsConfig := cors.DefaultConfig() 95 | corsConfig.AllowOrigins = cfg.Server.CORSOrigins 96 | corsConfig.AllowCredentials = true 97 | corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization"} 98 | router.Use(cors.New(corsConfig)) 99 | 100 | // Create template functions 101 | funcMap := template.FuncMap{ 102 | "static": func(path string) string { 103 | // In development mode, refresh version on each request 104 | if !cfg.Server.StaticFilesEmbedded { 105 | versionManager.RefreshVersion(path) 106 | } 107 | return versionManager.GetVersionedURL("/static" + path) 108 | }, 109 | } 110 | 111 | // Load HTML templates 112 | if cfg.Server.StaticFilesEmbedded { 113 | // Load embedded templates with custom functions 114 | tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFiles, "templates/*.html")) 115 | router.SetHTMLTemplate(tmpl) 116 | 117 | // Serve embedded static files - need to use sub filesystem to strip the "static" prefix 118 | staticFS, err := fs.Sub(staticFiles, "static") 119 | if err != nil { 120 | log.Fatalf("Failed to create static sub filesystem: %v", err) 121 | } 122 | router.StaticFS("/static", http.FS(staticFS)) 123 | } else { 124 | // Load templates from file system (development mode) with custom functions 125 | tmpl := template.Must(template.New("").Funcs(funcMap).ParseGlob("cmd/server/templates/*.html")) 126 | router.SetHTMLTemplate(tmpl) 127 | router.Static("/static", "cmd/server/static") 128 | } 129 | 130 | // Health check endpoint 131 | if cfg.Server.EnableHealthCheck { 132 | router.GET("/health", func(c *gin.Context) { 133 | c.JSON(http.StatusOK, gin.H{ 134 | "status": "healthy", 135 | "service": "game2048", 136 | }) 137 | }) 138 | } 139 | 140 | // Authentication routes 141 | authRoutes := router.Group("/auth") 142 | { 143 | authRoutes.GET("/login", authHandler.Login) 144 | authRoutes.GET("/callback", authHandler.Callback) 145 | authRoutes.POST("/logout", authHandler.Logout) 146 | authRoutes.GET("/me", authHandler.AuthMiddleware(), authHandler.Me) 147 | } 148 | 149 | // Public pages 150 | router.GET("/leaderboard", func(c *gin.Context) { 151 | c.HTML(http.StatusOK, "leaderboard.html", gin.H{ 152 | "title": "2048 Game - Leaderboards", 153 | }) 154 | }) 155 | 156 | // WebSocket endpoint 157 | router.GET("/ws", hub.HandleWebSocket) 158 | 159 | // Public API routes (no authentication required) 160 | publicAPI := router.Group("/api/public") 161 | { 162 | publicAPI.GET("/leaderboard", leaderboardHandler.GetLeaderboard) 163 | } 164 | 165 | // API routes (protected) 166 | apiRoutes := router.Group("/api") 167 | apiRoutes.Use(authHandler.AuthMiddleware()) 168 | { 169 | // Admin endpoints 170 | apiRoutes.GET("/admin/refresh-cache", leaderboardHandler.RefreshCache) 171 | 172 | // Game endpoints could be added here if needed 173 | // For now, all game logic is handled via WebSocket 174 | } 175 | 176 | // Serve the main game page 177 | router.GET("/", authHandler.OptionalAuthMiddleware(), func(c *gin.Context) { 178 | userID, exists := c.Get("user_id") 179 | if !exists { 180 | // User not authenticated, show login page 181 | c.HTML(http.StatusOK, "login.html", gin.H{ 182 | "title": "2048 Game - Login", 183 | }) 184 | return 185 | } 186 | 187 | // User authenticated, show game page 188 | user, err := db.GetUser(userID.(string)) 189 | if err != nil { 190 | log.Printf("Failed to get user: %v", err) 191 | c.HTML(http.StatusInternalServerError, "error.html", gin.H{ 192 | "error": "Failed to load user data", 193 | }) 194 | return 195 | } 196 | 197 | c.HTML(http.StatusOK, "game.html", gin.H{ 198 | "title": "2048 Game", 199 | "user": user, 200 | }) 201 | }) 202 | 203 | // Start server with graceful shutdown 204 | srv := &http.Server{ 205 | Addr: cfg.GetServerAddress(), 206 | Handler: router, 207 | } 208 | 209 | go func() { 210 | log.Printf("Starting server on %s", cfg.GetServerAddress()) 211 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 212 | log.Fatalf("Failed to start server: %v", err) 213 | } 214 | }() 215 | 216 | quit := make(chan os.Signal, 1) 217 | signal.Notify(quit, os.Interrupt, syscall.SIGTERM) 218 | <-quit 219 | log.Println("Shutting down server...") 220 | 221 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 222 | defer cancel() 223 | if err := srv.Shutdown(ctx); err != nil { 224 | log.Fatalf("Server forced to shutdown: %v", err) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /nginx/game2048.conf: -------------------------------------------------------------------------------- 1 | # Nginx configuration for 2048 Game 2 | # Place this file in /etc/nginx/sites-available/ and symlink to /etc/nginx/sites-enabled/ 3 | 4 | # Upstream backend servers 5 | upstream game2048_backend { 6 | # If running locally 7 | server 127.0.0.1:8080; 8 | 9 | # If running in Docker on same host 10 | # server 127.0.0.1:8080; 11 | 12 | # If running in Docker Compose 13 | # server game2048-backend:8080; 14 | 15 | # For load balancing (multiple instances) 16 | # server 127.0.0.1:8080; 17 | # server 127.0.0.1:8081; 18 | 19 | # Health check and failover 20 | keepalive 32; 21 | } 22 | 23 | # Rate limiting zones 24 | limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; 25 | limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s; 26 | limit_req_zone $binary_remote_addr zone=ws:10m rate=20r/s; 27 | limit_req_zone $binary_remote_addr zone=admin:10m rate=1r/s; 28 | 29 | # Main server block 30 | server { 31 | listen 80; 32 | listen [::]:80; 33 | 34 | # Replace with your domain 35 | server_name your-domain.com www.your-domain.com; 36 | 37 | # Security headers 38 | add_header X-Frame-Options "SAMEORIGIN" always; 39 | add_header X-Content-Type-Options "nosniff" always; 40 | add_header X-XSS-Protection "1; mode=block" always; 41 | add_header Referrer-Policy "strict-origin-when-cross-origin" always; 42 | 43 | # Gzip compression 44 | gzip on; 45 | gzip_vary on; 46 | gzip_min_length 1024; 47 | gzip_types 48 | text/plain 49 | text/css 50 | text/xml 51 | text/javascript 52 | application/javascript 53 | application/json 54 | application/xml+rss 55 | application/atom+xml 56 | image/svg+xml; 57 | 58 | # Static file caching 59 | location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { 60 | proxy_pass http://game2048_backend; 61 | proxy_set_header Host $host; 62 | proxy_set_header X-Real-IP $remote_addr; 63 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 64 | proxy_set_header X-Forwarded-Proto $scheme; 65 | 66 | # Cache static files for 1 year (versioned URLs handle cache busting) 67 | expires 1y; 68 | add_header Cache-Control "public, immutable"; 69 | 70 | # CORS headers for static assets if needed 71 | add_header Access-Control-Allow-Origin "*"; 72 | } 73 | 74 | # WebSocket connections 75 | location /ws { 76 | proxy_pass http://game2048_backend; 77 | 78 | # WebSocket specific headers 79 | proxy_http_version 1.1; 80 | proxy_set_header Upgrade $http_upgrade; 81 | proxy_set_header Connection "upgrade"; 82 | 83 | # Standard proxy headers 84 | proxy_set_header Host $host; 85 | proxy_set_header X-Real-IP $remote_addr; 86 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 87 | proxy_set_header X-Forwarded-Proto $scheme; 88 | 89 | # WebSocket timeout settings 90 | proxy_read_timeout 86400s; 91 | proxy_send_timeout 86400s; 92 | 93 | # Rate limiting for WebSocket connections 94 | limit_req zone=ws burst=10 nodelay; 95 | } 96 | 97 | # API endpoints with rate limiting 98 | location /api/ { 99 | proxy_pass http://game2048_backend; 100 | proxy_set_header Host $host; 101 | proxy_set_header X-Real-IP $remote_addr; 102 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 103 | proxy_set_header X-Forwarded-Proto $scheme; 104 | 105 | # Rate limiting for API calls 106 | limit_req zone=api burst=20 nodelay; 107 | 108 | # API response caching (optional) 109 | # proxy_cache api_cache; 110 | # proxy_cache_valid 200 5m; 111 | # proxy_cache_key "$scheme$request_method$host$request_uri"; 112 | } 113 | 114 | # Authentication endpoints with stricter rate limiting 115 | location /auth/ { 116 | proxy_pass http://game2048_backend; 117 | proxy_set_header Host $host; 118 | proxy_set_header X-Real-IP $remote_addr; 119 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 120 | proxy_set_header X-Forwarded-Proto $scheme; 121 | 122 | # Stricter rate limiting for auth endpoints 123 | limit_req zone=auth burst=5 nodelay; 124 | } 125 | 126 | # Health check endpoint 127 | location /health { 128 | proxy_pass http://game2048_backend; 129 | proxy_set_header Host $host; 130 | proxy_set_header X-Real-IP $remote_addr; 131 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 132 | proxy_set_header X-Forwarded-Proto $scheme; 133 | 134 | # No rate limiting for health checks 135 | access_log off; 136 | } 137 | 138 | # All other requests (main app) 139 | location / { 140 | proxy_pass http://game2048_backend; 141 | proxy_set_header Host $host; 142 | proxy_set_header X-Real-IP $remote_addr; 143 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 144 | proxy_set_header X-Forwarded-Proto $scheme; 145 | 146 | # Timeout settings 147 | proxy_connect_timeout 30s; 148 | proxy_send_timeout 30s; 149 | proxy_read_timeout 30s; 150 | 151 | # Buffer settings 152 | proxy_buffering on; 153 | proxy_buffer_size 4k; 154 | proxy_buffers 8 4k; 155 | } 156 | 157 | # Security: Block access to sensitive files 158 | location ~ /\. { 159 | deny all; 160 | access_log off; 161 | log_not_found off; 162 | } 163 | 164 | # Custom error pages (optional) 165 | error_page 502 503 504 /50x.html; 166 | location = /50x.html { 167 | root /usr/share/nginx/html; 168 | } 169 | } 170 | 171 | # HTTPS redirect (uncomment when you have SSL certificate) 172 | # server { 173 | # listen 80; 174 | # listen [::]:80; 175 | # server_name your-domain.com www.your-domain.com; 176 | # return 301 https://$server_name$request_uri; 177 | # } 178 | 179 | # HTTPS server block (uncomment and configure when you have SSL certificate) 180 | # server { 181 | # listen 443 ssl http2; 182 | # listen [::]:443 ssl http2; 183 | # server_name your-domain.com www.your-domain.com; 184 | # 185 | # # SSL certificate paths (adjust as needed) 186 | # ssl_certificate /path/to/your/certificate.crt; 187 | # ssl_certificate_key /path/to/your/private.key; 188 | # 189 | # # SSL configuration 190 | # ssl_protocols TLSv1.2 TLSv1.3; 191 | # ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; 192 | # ssl_prefer_server_ciphers off; 193 | # ssl_session_cache shared:SSL:10m; 194 | # ssl_session_timeout 10m; 195 | # 196 | # # HSTS header 197 | # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; 198 | # 199 | # # Include all the location blocks from the HTTP server above 200 | # # ... (copy all location blocks from above) 201 | # } 202 | -------------------------------------------------------------------------------- /backend/cmd/server/static/css/mobile.css: -------------------------------------------------------------------------------- 1 | /* Mobile-specific styles for 2048 Game */ 2 | 3 | /* Touch-friendly interactions */ 4 | @media (max-width: 768px) { 5 | /* Prevent text selection on touch */ 6 | .game-board, 7 | .tile, 8 | button { 9 | -webkit-user-select: none; 10 | -moz-user-select: none; 11 | -ms-user-select: none; 12 | user-select: none; 13 | -webkit-touch-callout: none; 14 | -webkit-tap-highlight-color: transparent; 15 | } 16 | 17 | /* Improve touch targets */ 18 | button { 19 | min-height: 44px; 20 | min-width: 44px; 21 | } 22 | 23 | .tab-btn { 24 | padding: 12px 8px; 25 | font-size: 0.9rem; 26 | } 27 | 28 | /* Game board adjustments */ 29 | .game-board { 30 | touch-action: none; 31 | padding: 8px; 32 | grid-gap: 8px; 33 | } 34 | 35 | /* Header adjustments */ 36 | .game-header { 37 | padding: 10px 0; 38 | } 39 | 40 | .game-title { 41 | font-size: 2.5rem; 42 | } 43 | 44 | .user-info { 45 | flex-wrap: wrap; 46 | gap: 8px; 47 | } 48 | 49 | .user-name { 50 | font-size: 0.9rem; 51 | } 52 | 53 | .logout-btn { 54 | padding: 8px 12px; 55 | font-size: 0.8rem; 56 | } 57 | 58 | /* Score and controls */ 59 | .game-info { 60 | gap: 10px; 61 | } 62 | 63 | .score-box { 64 | padding: 8px 16px; 65 | } 66 | 67 | .score-label { 68 | font-size: 0.7rem; 69 | } 70 | 71 | .score-value { 72 | font-size: 1.2rem; 73 | } 74 | 75 | .new-game-btn { 76 | padding: 8px 16px; 77 | font-size: 0.9rem; 78 | } 79 | 80 | /* Instructions */ 81 | .instructions { 82 | padding: 12px; 83 | font-size: 0.9rem; 84 | } 85 | 86 | /* Leaderboard adjustments */ 87 | .leaderboard-section { 88 | margin-bottom: 80px; /* Space for connection status */ 89 | } 90 | 91 | .leaderboard-content { 92 | padding: 15px; 93 | } 94 | 95 | .leaderboard-entry { 96 | padding: 10px 12px; 97 | } 98 | 99 | .leaderboard-entry .user-info { 100 | margin-left: 10px; 101 | gap: 8px; 102 | } 103 | 104 | .leaderboard-entry .user-avatar { 105 | width: 28px; 106 | height: 28px; 107 | } 108 | 109 | .leaderboard-entry .user-name { 110 | font-size: 0.9rem; 111 | } 112 | 113 | .leaderboard-entry .score { 114 | font-size: 1rem; 115 | } 116 | 117 | /* Connection status adjustments */ 118 | .connection-status { 119 | bottom: 10px; 120 | right: 10px; 121 | padding: 6px 12px; 122 | font-size: 0.8rem; 123 | } 124 | 125 | /* Game overlay adjustments */ 126 | .overlay-content { 127 | padding: 20px; 128 | } 129 | 130 | .overlay-message { 131 | font-size: 1.2rem; 132 | margin-bottom: 15px; 133 | } 134 | 135 | .overlay-btn { 136 | padding: 8px 16px; 137 | font-size: 0.9rem; 138 | } 139 | } 140 | 141 | /* Extra small screens */ 142 | @media (max-width: 480px) { 143 | .game-container { 144 | padding: 8px; 145 | } 146 | 147 | .game-title { 148 | font-size: 2rem; 149 | } 150 | 151 | .game-subtitle { 152 | font-size: 0.9rem; 153 | } 154 | 155 | .game-board { 156 | padding: 6px; 157 | grid-gap: 6px; 158 | } 159 | 160 | .score-box { 161 | padding: 6px 12px; 162 | } 163 | 164 | .score-label { 165 | font-size: 0.6rem; 166 | } 167 | 168 | .score-value { 169 | font-size: 1rem; 170 | } 171 | 172 | .new-game-btn { 173 | padding: 6px 12px; 174 | font-size: 0.8rem; 175 | } 176 | 177 | .instructions { 178 | padding: 10px; 179 | font-size: 0.8rem; 180 | } 181 | 182 | .tab-btn { 183 | padding: 10px 6px; 184 | font-size: 0.8rem; 185 | } 186 | 187 | .leaderboard-content { 188 | padding: 10px; 189 | } 190 | 191 | .leaderboard-entry { 192 | padding: 8px 10px; 193 | } 194 | 195 | .leaderboard-entry .rank { 196 | font-size: 1rem; 197 | min-width: 35px; 198 | } 199 | 200 | .leaderboard-entry .user-info { 201 | margin-left: 8px; 202 | gap: 6px; 203 | } 204 | 205 | .leaderboard-entry .user-avatar { 206 | width: 24px; 207 | height: 24px; 208 | } 209 | 210 | .leaderboard-entry .user-name { 211 | font-size: 0.8rem; 212 | } 213 | 214 | .leaderboard-entry .score { 215 | font-size: 0.9rem; 216 | } 217 | 218 | .you-badge { 219 | font-size: 0.6rem; 220 | padding: 1px 4px; 221 | } 222 | } 223 | 224 | /* Landscape orientation on mobile */ 225 | @media (max-width: 768px) and (orientation: landscape) { 226 | .game-container { 227 | display: grid; 228 | grid-template-columns: 1fr 300px; 229 | grid-gap: 20px; 230 | max-width: none; 231 | padding: 10px; 232 | } 233 | 234 | .game-main { 235 | display: flex; 236 | flex-direction: column; 237 | } 238 | 239 | .game-header { 240 | margin-bottom: 10px; 241 | } 242 | 243 | .game-info { 244 | margin-bottom: 10px; 245 | } 246 | 247 | .game-board-container { 248 | flex: 1; 249 | display: flex; 250 | align-items: center; 251 | justify-content: center; 252 | margin-bottom: 10px; 253 | } 254 | 255 | .game-board { 256 | max-width: 300px; 257 | max-height: 300px; 258 | } 259 | 260 | .instructions { 261 | margin-bottom: 0; 262 | } 263 | 264 | .leaderboard-section { 265 | margin-bottom: 0; 266 | height: fit-content; 267 | } 268 | 269 | .leaderboard-content { 270 | max-height: 400px; 271 | overflow-y: auto; 272 | } 273 | } 274 | 275 | /* iOS Safari specific fixes */ 276 | @supports (-webkit-touch-callout: none) { 277 | .game-board { 278 | /* Prevent bounce scrolling */ 279 | overscroll-behavior: none; 280 | } 281 | 282 | /* Fix viewport height on iOS */ 283 | .container { 284 | min-height: -webkit-fill-available; 285 | } 286 | } 287 | 288 | /* Android Chrome specific fixes */ 289 | @media screen and (max-width: 768px) { 290 | /* Prevent zoom on input focus */ 291 | input, select, textarea { 292 | font-size: 16px; 293 | } 294 | 295 | /* Improve scrolling performance */ 296 | .leaderboard-content { 297 | -webkit-overflow-scrolling: touch; 298 | } 299 | } 300 | 301 | /* High DPI displays */ 302 | @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { 303 | .tile { 304 | /* Sharper text rendering on high DPI */ 305 | -webkit-font-smoothing: antialiased; 306 | -moz-osx-font-smoothing: grayscale; 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /backend/internal/cache/redis.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "game2048/internal/config" 11 | "game2048/pkg/models" 12 | 13 | "github.com/redis/go-redis/v9" 14 | ) 15 | 16 | // RedisCache implements caching using Redis 17 | type RedisCache struct { 18 | client *redis.Client 19 | ctx context.Context 20 | } 21 | 22 | // Cache interface defines caching operations 23 | type Cache interface { 24 | // Session management 25 | SetSession(key string, value interface{}, expiration time.Duration) error 26 | GetSession(key string, dest interface{}) error 27 | DeleteSession(key string) error 28 | 29 | // OAuth2 state management 30 | SetOAuth2State(state string, expiration time.Duration) error 31 | ValidateOAuth2State(state string) bool 32 | 33 | // Leaderboard caching 34 | SetLeaderboard(leaderboardType models.LeaderboardType, entries []models.LeaderboardEntry, expiration time.Duration) error 35 | GetLeaderboard(leaderboardType models.LeaderboardType) ([]models.LeaderboardEntry, error) 36 | InvalidateLeaderboard(leaderboardType models.LeaderboardType) error 37 | 38 | // Game session caching 39 | SetGameSession(userID string, game *models.GameState, expiration time.Duration) error 40 | GetGameSession(userID string) (*models.GameState, error) 41 | DeleteGameSession(userID string) error 42 | 43 | // JWT blacklist 44 | BlacklistJWT(tokenID string, expiration time.Duration) error 45 | IsJWTBlacklisted(tokenID string) bool 46 | 47 | // Generic operations 48 | Set(key string, value interface{}, expiration time.Duration) error 49 | Get(key string, dest interface{}) error 50 | Delete(key string) error 51 | Exists(key string) bool 52 | Close() error 53 | } 54 | 55 | // NewRedisCache creates a new Redis cache instance 56 | func NewRedisCache(cfg *config.Config) (*RedisCache, error) { 57 | // Create Redis client 58 | rdb := redis.NewClient(&redis.Options{ 59 | Addr: fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port), 60 | Password: cfg.Redis.Password, 61 | DB: cfg.Redis.DB, 62 | }) 63 | 64 | ctx := context.Background() 65 | 66 | // Test connection 67 | if err := rdb.Ping(ctx).Err(); err != nil { 68 | return nil, fmt.Errorf("failed to connect to Redis: %w", err) 69 | } 70 | 71 | log.Println("Successfully connected to Redis") 72 | 73 | return &RedisCache{ 74 | client: rdb, 75 | ctx: ctx, 76 | }, nil 77 | } 78 | 79 | // Close closes the Redis connection 80 | func (r *RedisCache) Close() error { 81 | return r.client.Close() 82 | } 83 | 84 | // Set stores a value in Redis 85 | func (r *RedisCache) Set(key string, value interface{}, expiration time.Duration) error { 86 | data, err := json.Marshal(value) 87 | if err != nil { 88 | return fmt.Errorf("failed to marshal value: %w", err) 89 | } 90 | 91 | return r.client.Set(r.ctx, key, data, expiration).Err() 92 | } 93 | 94 | // Get retrieves a value from Redis 95 | func (r *RedisCache) Get(key string, dest interface{}) error { 96 | data, err := r.client.Get(r.ctx, key).Result() 97 | if err != nil { 98 | if err == redis.Nil { 99 | return fmt.Errorf("key not found") 100 | } 101 | return fmt.Errorf("failed to get value: %w", err) 102 | } 103 | 104 | return json.Unmarshal([]byte(data), dest) 105 | } 106 | 107 | // Delete removes a key from Redis 108 | func (r *RedisCache) Delete(key string) error { 109 | return r.client.Del(r.ctx, key).Err() 110 | } 111 | 112 | // Exists checks if a key exists in Redis 113 | func (r *RedisCache) Exists(key string) bool { 114 | result, err := r.client.Exists(r.ctx, key).Result() 115 | if err != nil { 116 | return false 117 | } 118 | return result > 0 119 | } 120 | 121 | // SetSession stores a session value 122 | func (r *RedisCache) SetSession(key string, value interface{}, expiration time.Duration) error { 123 | sessionKey := fmt.Sprintf("session:%s", key) 124 | return r.Set(sessionKey, value, expiration) 125 | } 126 | 127 | // GetSession retrieves a session value 128 | func (r *RedisCache) GetSession(key string, dest interface{}) error { 129 | sessionKey := fmt.Sprintf("session:%s", key) 130 | return r.Get(sessionKey, dest) 131 | } 132 | 133 | // DeleteSession removes a session 134 | func (r *RedisCache) DeleteSession(key string) error { 135 | sessionKey := fmt.Sprintf("session:%s", key) 136 | return r.Delete(sessionKey) 137 | } 138 | 139 | // SetOAuth2State stores an OAuth2 state 140 | func (r *RedisCache) SetOAuth2State(state string, expiration time.Duration) error { 141 | stateKey := fmt.Sprintf("oauth2:state:%s", state) 142 | return r.client.Set(r.ctx, stateKey, "valid", expiration).Err() 143 | } 144 | 145 | // ValidateOAuth2State validates and removes an OAuth2 state 146 | func (r *RedisCache) ValidateOAuth2State(state string) bool { 147 | stateKey := fmt.Sprintf("oauth2:state:%s", state) 148 | 149 | // Use a Lua script to atomically check and delete 150 | script := ` 151 | if redis.call("exists", KEYS[1]) == 1 then 152 | redis.call("del", KEYS[1]) 153 | return 1 154 | else 155 | return 0 156 | end 157 | ` 158 | 159 | result, err := r.client.Eval(r.ctx, script, []string{stateKey}).Result() 160 | if err != nil { 161 | return false 162 | } 163 | 164 | return result.(int64) == 1 165 | } 166 | 167 | // SetLeaderboard caches leaderboard entries 168 | func (r *RedisCache) SetLeaderboard(leaderboardType models.LeaderboardType, entries []models.LeaderboardEntry, expiration time.Duration) error { 169 | leaderboardKey := fmt.Sprintf("leaderboard:%s", string(leaderboardType)) 170 | return r.Set(leaderboardKey, entries, expiration) 171 | } 172 | 173 | // GetLeaderboard retrieves cached leaderboard entries 174 | func (r *RedisCache) GetLeaderboard(leaderboardType models.LeaderboardType) ([]models.LeaderboardEntry, error) { 175 | leaderboardKey := fmt.Sprintf("leaderboard:%s", string(leaderboardType)) 176 | var entries []models.LeaderboardEntry 177 | err := r.Get(leaderboardKey, &entries) 178 | return entries, err 179 | } 180 | 181 | // InvalidateLeaderboard removes cached leaderboard 182 | func (r *RedisCache) InvalidateLeaderboard(leaderboardType models.LeaderboardType) error { 183 | leaderboardKey := fmt.Sprintf("leaderboard:%s", string(leaderboardType)) 184 | return r.Delete(leaderboardKey) 185 | } 186 | 187 | // SetGameSession caches a game session 188 | func (r *RedisCache) SetGameSession(userID string, game *models.GameState, expiration time.Duration) error { 189 | gameKey := fmt.Sprintf("game:session:%s", userID) 190 | return r.Set(gameKey, game, expiration) 191 | } 192 | 193 | // GetGameSession retrieves a cached game session 194 | func (r *RedisCache) GetGameSession(userID string) (*models.GameState, error) { 195 | gameKey := fmt.Sprintf("game:session:%s", userID) 196 | var game models.GameState 197 | err := r.Get(gameKey, &game) 198 | return &game, err 199 | } 200 | 201 | // DeleteGameSession removes a game session 202 | func (r *RedisCache) DeleteGameSession(userID string) error { 203 | gameKey := fmt.Sprintf("game:session:%s", userID) 204 | return r.Delete(gameKey) 205 | } 206 | 207 | // BlacklistJWT adds a JWT token to the blacklist 208 | func (r *RedisCache) BlacklistJWT(tokenID string, expiration time.Duration) error { 209 | blacklistKey := fmt.Sprintf("jwt:blacklist:%s", tokenID) 210 | return r.client.Set(r.ctx, blacklistKey, "blacklisted", expiration).Err() 211 | } 212 | 213 | // IsJWTBlacklisted checks if a JWT token is blacklisted 214 | func (r *RedisCache) IsJWTBlacklisted(tokenID string) bool { 215 | blacklistKey := fmt.Sprintf("jwt:blacklist:%s", tokenID) 216 | return r.Exists(blacklistKey) 217 | } 218 | -------------------------------------------------------------------------------- /backend/cmd/server/static/js/websocket.js: -------------------------------------------------------------------------------- 1 | // WebSocket connection management 2 | class GameWebSocket { 3 | constructor() { 4 | this.ws = null; 5 | this.reconnectAttempts = 0; 6 | this.maxReconnectAttempts = 5; 7 | this.reconnectDelay = 1000; 8 | this.messageHandlers = new Map(); 9 | this.connectionStatus = 'disconnected'; 10 | 11 | this.setupEventHandlers(); 12 | this.connect(); 13 | } 14 | 15 | setupEventHandlers() { 16 | // Register message handlers 17 | this.onMessage('game_state', (data) => { 18 | if (window.canvasGame) { 19 | window.canvasGame.updateGameState(data); 20 | } else { 21 | // Cache the game state until canvas game is ready 22 | this.cachedGameState = data; 23 | console.log('Cached game state for later use'); 24 | } 25 | }); 26 | 27 | this.onMessage('leaderboard', (data) => { 28 | if (window.leaderboard) { 29 | window.leaderboard.updateLeaderboard(data); 30 | } 31 | }); 32 | 33 | this.onMessage('leaderboard_update', (data) => { 34 | if (window.leaderboard) { 35 | window.leaderboard.updateLeaderboard(data); 36 | } 37 | }); 38 | 39 | this.onMessage('error', (data) => { 40 | console.error('WebSocket error:', data.message); 41 | this.showError(data.message); 42 | }); 43 | } 44 | 45 | connect() { 46 | try { 47 | // Get auth token 48 | const token = localStorage.getItem('auth_token') || this.getCookie('auth_token'); 49 | if (!token) { 50 | console.error('No auth token found'); 51 | this.updateConnectionStatus('disconnected', 'Not authenticated'); 52 | return; 53 | } 54 | 55 | // Create WebSocket connection 56 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 57 | const wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; 58 | 59 | this.ws = new WebSocket(wsUrl); 60 | this.updateConnectionStatus('connecting', 'Connecting...'); 61 | 62 | this.ws.onopen = () => { 63 | console.log('WebSocket connected'); 64 | this.reconnectAttempts = 0; 65 | this.updateConnectionStatus('connected', 'Connected'); 66 | }; 67 | 68 | this.ws.onmessage = (event) => { 69 | try { 70 | const message = JSON.parse(event.data); 71 | this.handleMessage(message); 72 | } catch (error) { 73 | console.error('Failed to parse WebSocket message:', error); 74 | } 75 | }; 76 | 77 | this.ws.onclose = (event) => { 78 | console.log('WebSocket disconnected:', event.code, event.reason); 79 | this.updateConnectionStatus('disconnected', 'Disconnected'); 80 | 81 | // Attempt to reconnect 82 | if (this.reconnectAttempts < this.maxReconnectAttempts) { 83 | this.reconnectAttempts++; 84 | const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); 85 | console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); 86 | 87 | setTimeout(() => { 88 | this.connect(); 89 | }, delay); 90 | } else { 91 | this.updateConnectionStatus('disconnected', 'Connection failed'); 92 | this.showError('Connection lost. Please refresh the page.'); 93 | } 94 | }; 95 | 96 | this.ws.onerror = (error) => { 97 | console.error('WebSocket error:', error); 98 | this.updateConnectionStatus('disconnected', 'Connection error'); 99 | }; 100 | 101 | } catch (error) { 102 | console.error('Failed to create WebSocket connection:', error); 103 | this.updateConnectionStatus('disconnected', 'Connection failed'); 104 | } 105 | } 106 | 107 | disconnect() { 108 | if (this.ws) { 109 | this.ws.close(); 110 | this.ws = null; 111 | } 112 | } 113 | 114 | send(type, data = {}) { 115 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 116 | const message = { 117 | type: type, 118 | data: data 119 | }; 120 | this.ws.send(JSON.stringify(message)); 121 | } else { 122 | console.error('WebSocket not connected'); 123 | this.showError('Not connected to server'); 124 | } 125 | } 126 | 127 | onMessage(type, handler) { 128 | this.messageHandlers.set(type, handler); 129 | } 130 | 131 | handleMessage(message) { 132 | const handler = this.messageHandlers.get(message.type); 133 | if (handler) { 134 | handler(message.data); 135 | } else { 136 | console.warn('No handler for message type:', message.type); 137 | } 138 | } 139 | 140 | updateConnectionStatus(status, text) { 141 | this.connectionStatus = status; 142 | 143 | const statusIndicator = document.getElementById('status-indicator'); 144 | const statusText = document.getElementById('status-text'); 145 | 146 | if (statusIndicator && statusText) { 147 | statusIndicator.className = `status-indicator ${status}`; 148 | statusText.textContent = text; 149 | } 150 | } 151 | 152 | showError(message) { 153 | // Create or update error notification 154 | let errorDiv = document.getElementById('error-notification'); 155 | if (!errorDiv) { 156 | errorDiv = document.createElement('div'); 157 | errorDiv.id = 'error-notification'; 158 | errorDiv.style.cssText = ` 159 | position: fixed; 160 | top: 20px; 161 | right: 20px; 162 | background: #f44336; 163 | color: white; 164 | padding: 15px 20px; 165 | border-radius: 8px; 166 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 167 | z-index: 1000; 168 | max-width: 300px; 169 | font-size: 14px; 170 | line-height: 1.4; 171 | `; 172 | document.body.appendChild(errorDiv); 173 | } 174 | 175 | errorDiv.textContent = message; 176 | errorDiv.style.display = 'block'; 177 | 178 | // Auto-hide after 5 seconds 179 | setTimeout(() => { 180 | if (errorDiv) { 181 | errorDiv.style.display = 'none'; 182 | } 183 | }, 5000); 184 | } 185 | 186 | getCookie(name) { 187 | const value = `; ${document.cookie}`; 188 | const parts = value.split(`; ${name}=`); 189 | if (parts.length === 2) return parts.pop().split(';').shift(); 190 | return null; 191 | } 192 | } 193 | 194 | // Initialize WebSocket connection 195 | window.gameWS = new GameWebSocket(); 196 | -------------------------------------------------------------------------------- /backend/internal/game/engine.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "game2048/pkg/models" 5 | "math/rand" 6 | "time" 7 | ) 8 | 9 | // Engine handles the core 2048 game logic 10 | type Engine struct { 11 | rng *rand.Rand 12 | } 13 | 14 | // NewEngine creates a new game engine 15 | func NewEngine() *Engine { 16 | return &Engine{ 17 | rng: rand.New(rand.NewSource(time.Now().UnixNano())), 18 | } 19 | } 20 | 21 | // NewGame creates a new game with initial tiles 22 | func (e *Engine) NewGame() models.Board { 23 | board := models.NewBoard() 24 | 25 | // Add two initial tiles 26 | e.addRandomTile(&board) 27 | e.addRandomTile(&board) 28 | 29 | return board 30 | } 31 | 32 | // Move executes a move in the given direction and returns the new board and score gained 33 | func (e *Engine) Move(board models.Board, direction models.Direction) (models.Board, int, bool) { 34 | newBoard := board.Copy() 35 | scoreGained := 0 36 | moved := false 37 | 38 | switch direction { 39 | case models.DirectionUp: 40 | scoreGained, moved = e.moveUp(&newBoard) 41 | case models.DirectionDown: 42 | scoreGained, moved = e.moveDown(&newBoard) 43 | case models.DirectionLeft: 44 | scoreGained, moved = e.moveLeft(&newBoard) 45 | case models.DirectionRight: 46 | scoreGained, moved = e.moveRight(&newBoard) 47 | } 48 | 49 | // Add a new tile if the move was valid 50 | if moved { 51 | e.addRandomTile(&newBoard) 52 | } 53 | 54 | return newBoard, scoreGained, moved 55 | } 56 | 57 | // IsGameOver checks if the game is over (no valid moves available) 58 | func (e *Engine) IsGameOver(board models.Board) bool { 59 | // If there are empty cells, game is not over 60 | if !board.IsFull() { 61 | return false 62 | } 63 | 64 | // Check if any moves are possible 65 | directions := []models.Direction{ 66 | models.DirectionUp, models.DirectionDown, 67 | models.DirectionLeft, models.DirectionRight, 68 | } 69 | 70 | for _, dir := range directions { 71 | _, _, moved := e.Move(board, dir) 72 | if moved { 73 | return false 74 | } 75 | } 76 | 77 | return true 78 | } 79 | 80 | // IsVictory checks if the player has achieved victory (8192 tile) 81 | func (e *Engine) IsVictory(board models.Board) bool { 82 | return board.HasVictoryTile() 83 | } 84 | 85 | // addRandomTile adds a random tile (2 or 4) to an empty position 86 | func (e *Engine) addRandomTile(board *models.Board) bool { 87 | emptyCells := board.GetEmptyCells() 88 | if len(emptyCells) == 0 { 89 | return false 90 | } 91 | 92 | // Choose random empty cell 93 | pos := emptyCells[e.rng.Intn(len(emptyCells))] 94 | 95 | // 90% chance for 2, 10% chance for 4 96 | value := 2 97 | if e.rng.Float32() < 0.1 { 98 | value = 4 99 | } 100 | 101 | board.SetCell(pos[0], pos[1], value) 102 | return true 103 | } 104 | 105 | // moveLeft moves all tiles to the left and merges them 106 | func (e *Engine) moveLeft(board *models.Board) (int, bool) { 107 | scoreGained := 0 108 | moved := false 109 | 110 | for row := 0; row < models.BoardSize; row++ { 111 | // Extract non-zero values 112 | var line []int 113 | for col := 0; col < models.BoardSize; col++ { 114 | if board.GetCell(row, col) != 0 { 115 | line = append(line, board.GetCell(row, col)) 116 | } 117 | } 118 | 119 | // Merge adjacent equal values 120 | merged := e.mergeLine(line) 121 | scoreGained += merged.score 122 | 123 | // Check if anything changed 124 | for col := 0; col < models.BoardSize; col++ { 125 | newValue := 0 126 | if col < len(merged.line) { 127 | newValue = merged.line[col] 128 | } 129 | 130 | if board.GetCell(row, col) != newValue { 131 | moved = true 132 | } 133 | board.SetCell(row, col, newValue) 134 | } 135 | } 136 | 137 | return scoreGained, moved 138 | } 139 | 140 | // moveRight moves all tiles to the right 141 | func (e *Engine) moveRight(board *models.Board) (int, bool) { 142 | scoreGained := 0 143 | moved := false 144 | 145 | for row := 0; row < models.BoardSize; row++ { 146 | // Extract non-zero values (in reverse order) 147 | var line []int 148 | for col := models.BoardSize - 1; col >= 0; col-- { 149 | if board.GetCell(row, col) != 0 { 150 | line = append(line, board.GetCell(row, col)) 151 | } 152 | } 153 | 154 | // Merge adjacent equal values 155 | merged := e.mergeLine(line) 156 | scoreGained += merged.score 157 | 158 | // Place back in reverse order 159 | for col := 0; col < models.BoardSize; col++ { 160 | newValue := 0 161 | if col < len(merged.line) { 162 | newValue = merged.line[col] 163 | } 164 | 165 | actualCol := models.BoardSize - 1 - col 166 | if board.GetCell(row, actualCol) != newValue { 167 | moved = true 168 | } 169 | board.SetCell(row, actualCol, newValue) 170 | } 171 | } 172 | 173 | return scoreGained, moved 174 | } 175 | 176 | // moveUp moves all tiles up 177 | func (e *Engine) moveUp(board *models.Board) (int, bool) { 178 | scoreGained := 0 179 | moved := false 180 | 181 | for col := 0; col < models.BoardSize; col++ { 182 | // Extract non-zero values 183 | var line []int 184 | for row := 0; row < models.BoardSize; row++ { 185 | if board.GetCell(row, col) != 0 { 186 | line = append(line, board.GetCell(row, col)) 187 | } 188 | } 189 | 190 | // Merge adjacent equal values 191 | merged := e.mergeLine(line) 192 | scoreGained += merged.score 193 | 194 | // Check if anything changed 195 | for row := 0; row < models.BoardSize; row++ { 196 | newValue := 0 197 | if row < len(merged.line) { 198 | newValue = merged.line[row] 199 | } 200 | 201 | if board.GetCell(row, col) != newValue { 202 | moved = true 203 | } 204 | board.SetCell(row, col, newValue) 205 | } 206 | } 207 | 208 | return scoreGained, moved 209 | } 210 | 211 | // moveDown moves all tiles down 212 | func (e *Engine) moveDown(board *models.Board) (int, bool) { 213 | scoreGained := 0 214 | moved := false 215 | 216 | for col := 0; col < models.BoardSize; col++ { 217 | // Extract non-zero values (in reverse order) 218 | var line []int 219 | for row := models.BoardSize - 1; row >= 0; row-- { 220 | if board.GetCell(row, col) != 0 { 221 | line = append(line, board.GetCell(row, col)) 222 | } 223 | } 224 | 225 | // Merge adjacent equal values 226 | merged := e.mergeLine(line) 227 | scoreGained += merged.score 228 | 229 | // Place back in reverse order 230 | for row := 0; row < models.BoardSize; row++ { 231 | newValue := 0 232 | if row < len(merged.line) { 233 | newValue = merged.line[row] 234 | } 235 | 236 | actualRow := models.BoardSize - 1 - row 237 | if board.GetCell(actualRow, col) != newValue { 238 | moved = true 239 | } 240 | board.SetCell(actualRow, col, newValue) 241 | } 242 | } 243 | 244 | return scoreGained, moved 245 | } 246 | 247 | // mergeResult represents the result of merging a line 248 | type mergeResult struct { 249 | line []int 250 | score int 251 | } 252 | 253 | // mergeLine merges adjacent equal values in a line 254 | func (e *Engine) mergeLine(line []int) mergeResult { 255 | if len(line) <= 1 { 256 | return mergeResult{line: line, score: 0} 257 | } 258 | 259 | var result []int 260 | score := 0 261 | i := 0 262 | 263 | for i < len(line) { 264 | if i+1 < len(line) && line[i] == line[i+1] { 265 | // Merge the two tiles 266 | merged := line[i] * 2 267 | result = append(result, merged) 268 | score += merged 269 | i += 2 // Skip both tiles 270 | } else { 271 | // Keep the tile as is 272 | result = append(result, line[i]) 273 | i++ 274 | } 275 | } 276 | 277 | return mergeResult{line: result, score: score} 278 | } 279 | -------------------------------------------------------------------------------- /backend/migrations/001_initial_schema.sql: -------------------------------------------------------------------------------- 1 | -- Create users table 2 | CREATE TABLE IF NOT EXISTS users ( 3 | id VARCHAR(255) PRIMARY KEY, 4 | email VARCHAR(255) NOT NULL, 5 | name VARCHAR(255) NOT NULL, 6 | avatar VARCHAR(500), 7 | provider VARCHAR(50) NOT NULL, 8 | provider_id VARCHAR(255) NOT NULL, 9 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 10 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 11 | 12 | UNIQUE(provider, provider_id) 13 | ); 14 | 15 | -- Create index on email for faster lookups 16 | CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); 17 | CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id); 18 | 19 | -- Create games table 20 | CREATE TABLE IF NOT EXISTS games ( 21 | id UUID PRIMARY KEY, 22 | user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, 23 | board JSONB NOT NULL, 24 | score INTEGER NOT NULL DEFAULT 0, 25 | game_over BOOLEAN NOT NULL DEFAULT FALSE, 26 | victory BOOLEAN NOT NULL DEFAULT FALSE, 27 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 28 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 29 | ); 30 | 31 | -- Create indexes for games table 32 | CREATE INDEX IF NOT EXISTS idx_games_user_id ON games(user_id); 33 | CREATE INDEX IF NOT EXISTS idx_games_score ON games(score DESC); 34 | CREATE INDEX IF NOT EXISTS idx_games_created_at ON games(created_at DESC); 35 | CREATE INDEX IF NOT EXISTS idx_games_active ON games(user_id, game_over, victory) WHERE game_over = FALSE AND victory = FALSE; 36 | 37 | -- Create composite index for leaderboard queries 38 | CREATE INDEX IF NOT EXISTS idx_games_leaderboard_daily ON games(created_at, score DESC) WHERE (game_over = TRUE OR victory = TRUE); 39 | CREATE INDEX IF NOT EXISTS idx_games_leaderboard_weekly ON games(created_at, score DESC) WHERE (game_over = TRUE OR victory = TRUE); 40 | CREATE INDEX IF NOT EXISTS idx_games_leaderboard_all ON games(score DESC) WHERE (game_over = TRUE OR victory = TRUE); 41 | 42 | -- Create leaderboard cache tables for better performance 43 | CREATE TABLE IF NOT EXISTS leaderboard_daily ( 44 | user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, 45 | user_name VARCHAR(255) NOT NULL, 46 | user_avatar VARCHAR(500), 47 | score INTEGER NOT NULL, 48 | rank INTEGER NOT NULL, 49 | game_id UUID NOT NULL REFERENCES games(id) ON DELETE CASCADE, 50 | date DATE NOT NULL DEFAULT CURRENT_DATE, 51 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 52 | 53 | PRIMARY KEY (date, user_id) 54 | ); 55 | 56 | CREATE TABLE IF NOT EXISTS leaderboard_weekly ( 57 | user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, 58 | user_name VARCHAR(255) NOT NULL, 59 | user_avatar VARCHAR(500), 60 | score INTEGER NOT NULL, 61 | rank INTEGER NOT NULL, 62 | game_id UUID NOT NULL REFERENCES games(id) ON DELETE CASCADE, 63 | week_start DATE NOT NULL, 64 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 65 | 66 | PRIMARY KEY (week_start, user_id) 67 | ); 68 | 69 | CREATE TABLE IF NOT EXISTS leaderboard_monthly ( 70 | user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, 71 | user_name VARCHAR(255) NOT NULL, 72 | user_avatar VARCHAR(500), 73 | score INTEGER NOT NULL, 74 | rank INTEGER NOT NULL, 75 | game_id UUID NOT NULL REFERENCES games(id) ON DELETE CASCADE, 76 | month_start DATE NOT NULL, 77 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 78 | 79 | PRIMARY KEY (month_start, user_id) 80 | ); 81 | 82 | -- Create indexes for leaderboard cache tables 83 | CREATE INDEX IF NOT EXISTS idx_leaderboard_daily_score ON leaderboard_daily(date, score DESC); 84 | CREATE INDEX IF NOT EXISTS idx_leaderboard_weekly_score ON leaderboard_weekly(week_start, score DESC); 85 | CREATE INDEX IF NOT EXISTS idx_leaderboard_monthly_score ON leaderboard_monthly(month_start, score DESC); 86 | 87 | -- Create function to update updated_at timestamp 88 | CREATE OR REPLACE FUNCTION update_updated_at_column() 89 | RETURNS TRIGGER AS $$ 90 | BEGIN 91 | NEW.updated_at = CURRENT_TIMESTAMP; 92 | RETURN NEW; 93 | END; 94 | $$ language 'plpgsql'; 95 | 96 | -- Create triggers to automatically update updated_at 97 | CREATE TRIGGER update_users_updated_at 98 | BEFORE UPDATE ON users 99 | FOR EACH ROW 100 | EXECUTE FUNCTION update_updated_at_column(); 101 | 102 | CREATE TRIGGER update_games_updated_at 103 | BEFORE UPDATE ON games 104 | FOR EACH ROW 105 | EXECUTE FUNCTION update_updated_at_column(); 106 | 107 | -- Create function to refresh leaderboard cache 108 | CREATE OR REPLACE FUNCTION refresh_daily_leaderboard() 109 | RETURNS VOID AS $$ 110 | BEGIN 111 | -- Clear today's leaderboard 112 | DELETE FROM leaderboard_daily WHERE date = CURRENT_DATE; 113 | 114 | -- Insert today's top scores 115 | INSERT INTO leaderboard_daily (user_id, user_name, user_avatar, score, rank, game_id, date) 116 | SELECT 117 | g.user_id, 118 | u.name, 119 | u.avatar, 120 | g.score, 121 | ROW_NUMBER() OVER (ORDER BY g.score DESC), 122 | g.id, 123 | CURRENT_DATE 124 | FROM games g 125 | JOIN users u ON g.user_id = u.id 126 | WHERE (g.game_over = TRUE OR g.victory = TRUE) 127 | AND g.created_at >= CURRENT_DATE 128 | AND g.created_at < CURRENT_DATE + INTERVAL '1 day' 129 | ORDER BY g.score DESC 130 | LIMIT 100; 131 | END; 132 | $$ LANGUAGE plpgsql; 133 | 134 | -- Create function to refresh weekly leaderboard 135 | CREATE OR REPLACE FUNCTION refresh_weekly_leaderboard() 136 | RETURNS VOID AS $$ 137 | DECLARE 138 | week_start DATE := DATE_TRUNC('week', CURRENT_DATE)::DATE; 139 | BEGIN 140 | -- Clear this week's leaderboard 141 | DELETE FROM leaderboard_weekly WHERE week_start = week_start; 142 | 143 | -- Insert this week's top scores 144 | INSERT INTO leaderboard_weekly (user_id, user_name, user_avatar, score, rank, game_id, week_start) 145 | SELECT 146 | g.user_id, 147 | u.name, 148 | u.avatar, 149 | g.score, 150 | ROW_NUMBER() OVER (ORDER BY g.score DESC), 151 | g.id, 152 | week_start 153 | FROM games g 154 | JOIN users u ON g.user_id = u.id 155 | WHERE (g.game_over = TRUE OR g.victory = TRUE) 156 | AND g.created_at >= week_start 157 | AND g.created_at < week_start + INTERVAL '1 week' 158 | ORDER BY g.score DESC 159 | LIMIT 100; 160 | END; 161 | $$ LANGUAGE plpgsql; 162 | 163 | -- Create function to refresh monthly leaderboard 164 | CREATE OR REPLACE FUNCTION refresh_monthly_leaderboard() 165 | RETURNS VOID AS $$ 166 | DECLARE 167 | month_start DATE := DATE_TRUNC('month', CURRENT_DATE)::DATE; 168 | BEGIN 169 | -- Clear this month's leaderboard 170 | DELETE FROM leaderboard_monthly WHERE month_start = month_start; 171 | 172 | -- Insert this month's top scores 173 | INSERT INTO leaderboard_monthly (user_id, user_name, user_avatar, score, rank, game_id, month_start) 174 | SELECT 175 | g.user_id, 176 | u.name, 177 | u.avatar, 178 | g.score, 179 | ROW_NUMBER() OVER (ORDER BY g.score DESC), 180 | g.id, 181 | month_start 182 | FROM games g 183 | JOIN users u ON g.user_id = u.id 184 | WHERE (g.game_over = TRUE OR g.victory = TRUE) 185 | AND g.created_at >= month_start 186 | AND g.created_at < month_start + INTERVAL '1 month' 187 | ORDER BY g.score DESC 188 | LIMIT 100; 189 | END; 190 | $$ LANGUAGE plpgsql; 191 | -------------------------------------------------------------------------------- /backend/pkg/models/gorm_models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // User represents a user in the system using GORM 13 | type GormUser struct { 14 | ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` 15 | Email string `gorm:"type:varchar(255);not null" json:"email"` 16 | Name string `gorm:"type:varchar(255);not null" json:"name"` 17 | Avatar string `gorm:"type:varchar(500)" json:"avatar"` 18 | Provider string `gorm:"type:varchar(50);not null" json:"provider"` 19 | ProviderID string `gorm:"type:varchar(255);not null" json:"provider_id"` 20 | CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` 21 | UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` 22 | 23 | // Relationships 24 | Games []GormGame `gorm:"foreignKey:UserID" json:"games,omitempty"` 25 | } 26 | 27 | // TableName specifies the table name for GormUser 28 | func (GormUser) TableName() string { 29 | return "users" 30 | } 31 | 32 | // ToUser converts GormUser to User 33 | func (gu *GormUser) ToUser() *User { 34 | return &User{ 35 | ID: gu.ID, 36 | Email: gu.Email, 37 | Name: gu.Name, 38 | Avatar: gu.Avatar, 39 | Provider: gu.Provider, 40 | ProviderID: gu.ProviderID, 41 | CreatedAt: gu.CreatedAt, 42 | UpdatedAt: gu.UpdatedAt, 43 | } 44 | } 45 | 46 | // FromUser converts User to GormUser 47 | func (gu *GormUser) FromUser(u *User) { 48 | gu.ID = u.ID 49 | gu.Email = u.Email 50 | gu.Name = u.Name 51 | gu.Avatar = u.Avatar 52 | gu.Provider = u.Provider 53 | gu.ProviderID = u.ProviderID 54 | gu.CreatedAt = u.CreatedAt 55 | gu.UpdatedAt = u.UpdatedAt 56 | } 57 | 58 | // GormGame represents a game session using GORM 59 | type GormGame struct { 60 | ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` 61 | UserID string `gorm:"type:varchar(255);not null;index" json:"user_id"` 62 | Board BoardJSON `gorm:"type:jsonb;not null" json:"board"` 63 | Score int `gorm:"not null;default:0;index:idx_games_score" json:"score"` 64 | GameOver bool `gorm:"not null;default:false" json:"game_over"` 65 | Victory bool `gorm:"not null;default:false" json:"victory"` 66 | CreatedAt time.Time `gorm:"autoCreateTime;index:idx_games_created_at" json:"created_at"` 67 | UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` 68 | 69 | // Relationships 70 | User GormUser `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"` 71 | } 72 | 73 | // TableName specifies the table name for GormGame 74 | func (GormGame) TableName() string { 75 | return "games" 76 | } 77 | 78 | // BoardJSON is a custom type for handling JSON serialization of the game board 79 | type BoardJSON Board 80 | 81 | // Scan implements the sql.Scanner interface for reading from database 82 | func (b *BoardJSON) Scan(value interface{}) error { 83 | if value == nil { 84 | *b = BoardJSON{} 85 | return nil 86 | } 87 | 88 | bytes, ok := value.([]byte) 89 | if !ok { 90 | return fmt.Errorf("cannot scan %T into BoardJSON", value) 91 | } 92 | 93 | var board Board 94 | if err := json.Unmarshal(bytes, &board); err != nil { 95 | return err 96 | } 97 | 98 | *b = BoardJSON(board) 99 | return nil 100 | } 101 | 102 | // Value implements the driver.Valuer interface for writing to database 103 | func (b BoardJSON) Value() (driver.Value, error) { 104 | return json.Marshal(Board(b)) 105 | } 106 | 107 | // ToGameState converts GormGame to GameState 108 | func (gg *GormGame) ToGameState() *GameState { 109 | return &GameState{ 110 | ID: gg.ID, 111 | UserID: gg.UserID, 112 | Board: Board(gg.Board), 113 | Score: gg.Score, 114 | GameOver: gg.GameOver, 115 | Victory: gg.Victory, 116 | CreatedAt: gg.CreatedAt, 117 | UpdatedAt: gg.UpdatedAt, 118 | } 119 | } 120 | 121 | // FromGameState converts GameState to GormGame 122 | func (gg *GormGame) FromGameState(gs *GameState) { 123 | gg.ID = gs.ID 124 | gg.UserID = gs.UserID 125 | gg.Board = BoardJSON(gs.Board) 126 | gg.Score = gs.Score 127 | gg.GameOver = gs.GameOver 128 | gg.Victory = gs.Victory 129 | gg.CreatedAt = gs.CreatedAt 130 | gg.UpdatedAt = gs.UpdatedAt 131 | } 132 | 133 | // GormLeaderboardEntry represents a leaderboard entry using GORM 134 | type GormLeaderboardEntry struct { 135 | UserID string `gorm:"type:varchar(255);not null" json:"user_id"` 136 | UserName string `gorm:"type:varchar(255);not null" json:"user_name"` 137 | UserAvatar string `gorm:"type:varchar(500)" json:"user_avatar"` 138 | Score int `gorm:"not null" json:"score"` 139 | Rank int `gorm:"not null" json:"rank"` 140 | GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"` 141 | CreatedAt time.Time `json:"created_at"` 142 | } 143 | 144 | // ToLeaderboardEntry converts GormLeaderboardEntry to LeaderboardEntry 145 | func (gle *GormLeaderboardEntry) ToLeaderboardEntry() *LeaderboardEntry { 146 | return &LeaderboardEntry{ 147 | UserID: gle.UserID, 148 | UserName: gle.UserName, 149 | UserAvatar: gle.UserAvatar, 150 | Score: gle.Score, 151 | Rank: gle.Rank, 152 | GameID: gle.GameID, 153 | CreatedAt: gle.CreatedAt, 154 | } 155 | } 156 | 157 | // Daily leaderboard cache table 158 | type GormDailyLeaderboard struct { 159 | UserID string `gorm:"type:varchar(255);not null;primaryKey" json:"user_id"` 160 | UserName string `gorm:"type:varchar(255);not null" json:"user_name"` 161 | UserAvatar string `gorm:"type:varchar(500)" json:"user_avatar"` 162 | Score int `gorm:"not null;index:idx_daily_score" json:"score"` 163 | Rank int `gorm:"not null" json:"rank"` 164 | GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"` 165 | Date time.Time `gorm:"type:date;not null;primaryKey;default:CURRENT_DATE" json:"date"` 166 | CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` 167 | } 168 | 169 | func (GormDailyLeaderboard) TableName() string { 170 | return "leaderboard_daily" 171 | } 172 | 173 | // Weekly leaderboard cache table 174 | type GormWeeklyLeaderboard struct { 175 | UserID string `gorm:"type:varchar(255);not null;primaryKey" json:"user_id"` 176 | UserName string `gorm:"type:varchar(255);not null" json:"user_name"` 177 | UserAvatar string `gorm:"type:varchar(500)" json:"user_avatar"` 178 | Score int `gorm:"not null;index:idx_weekly_score" json:"score"` 179 | Rank int `gorm:"not null" json:"rank"` 180 | GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"` 181 | WeekStart time.Time `gorm:"type:date;not null;primaryKey" json:"week_start"` 182 | CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` 183 | } 184 | 185 | func (GormWeeklyLeaderboard) TableName() string { 186 | return "leaderboard_weekly" 187 | } 188 | 189 | // Monthly leaderboard cache table 190 | type GormMonthlyLeaderboard struct { 191 | UserID string `gorm:"type:varchar(255);not null;primaryKey" json:"user_id"` 192 | UserName string `gorm:"type:varchar(255);not null" json:"user_name"` 193 | UserAvatar string `gorm:"type:varchar(500)" json:"user_avatar"` 194 | Score int `gorm:"not null;index:idx_monthly_score" json:"score"` 195 | Rank int `gorm:"not null" json:"rank"` 196 | GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"` 197 | MonthStart time.Time `gorm:"type:date;not null;primaryKey" json:"month_start"` 198 | CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` 199 | } 200 | 201 | func (GormMonthlyLeaderboard) TableName() string { 202 | return "leaderboard_monthly" 203 | } 204 | -------------------------------------------------------------------------------- /backend/cmd/server/static/js/game.js: -------------------------------------------------------------------------------- 1 | // Game board management and UI 2 | class Game2048 { 3 | constructor() { 4 | this.board = Array(4).fill().map(() => Array(4).fill(0)); 5 | this.score = 0; 6 | this.gameOver = false; 7 | this.victory = false; 8 | 9 | this.setupEventListeners(); 10 | this.initializeBoard(); 11 | } 12 | 13 | setupEventListeners() { 14 | // Keyboard controls 15 | document.addEventListener('keydown', (e) => { 16 | if (this.gameOver || this.victory) return; 17 | 18 | let direction = null; 19 | switch (e.key) { 20 | case 'ArrowUp': 21 | direction = 'up'; 22 | break; 23 | case 'ArrowDown': 24 | direction = 'down'; 25 | break; 26 | case 'ArrowLeft': 27 | direction = 'left'; 28 | break; 29 | case 'ArrowRight': 30 | direction = 'right'; 31 | break; 32 | default: 33 | return; 34 | } 35 | 36 | e.preventDefault(); 37 | this.makeMove(direction); 38 | }); 39 | 40 | // Touch controls for mobile 41 | this.setupTouchControls(); 42 | } 43 | 44 | setupTouchControls() { 45 | const gameBoard = document.getElementById('game-board'); 46 | let startX = 0; 47 | let startY = 0; 48 | let endX = 0; 49 | let endY = 0; 50 | 51 | gameBoard.addEventListener('touchstart', (e) => { 52 | e.preventDefault(); 53 | const touch = e.touches[0]; 54 | startX = touch.clientX; 55 | startY = touch.clientY; 56 | }, { passive: false }); 57 | 58 | gameBoard.addEventListener('touchmove', (e) => { 59 | e.preventDefault(); 60 | }, { passive: false }); 61 | 62 | gameBoard.addEventListener('touchend', (e) => { 63 | e.preventDefault(); 64 | if (this.gameOver || this.victory) return; 65 | 66 | const touch = e.changedTouches[0]; 67 | endX = touch.clientX; 68 | endY = touch.clientY; 69 | 70 | const deltaX = endX - startX; 71 | const deltaY = endY - startY; 72 | const minSwipeDistance = 30; 73 | 74 | if (Math.abs(deltaX) < minSwipeDistance && Math.abs(deltaY) < minSwipeDistance) { 75 | return; 76 | } 77 | 78 | let direction = null; 79 | if (Math.abs(deltaX) > Math.abs(deltaY)) { 80 | // Horizontal swipe 81 | direction = deltaX > 0 ? 'right' : 'left'; 82 | } else { 83 | // Vertical swipe 84 | direction = deltaY > 0 ? 'down' : 'up'; 85 | } 86 | 87 | if (direction) { 88 | this.makeMove(direction); 89 | } 90 | }, { passive: false }); 91 | } 92 | 93 | initializeBoard() { 94 | const gameBoard = document.getElementById('game-board'); 95 | gameBoard.innerHTML = ''; 96 | 97 | // Create 16 tile containers 98 | for (let i = 0; i < 16; i++) { 99 | const tile = document.createElement('div'); 100 | tile.className = 'tile'; 101 | tile.id = `tile-${i}`; 102 | gameBoard.appendChild(tile); 103 | } 104 | 105 | this.updateDisplay(); 106 | } 107 | 108 | makeMove(direction) { 109 | if (window.gameWS) { 110 | window.gameWS.send('move', { direction: direction }); 111 | } 112 | } 113 | 114 | updateGameState(gameState) { 115 | this.board = gameState.board; 116 | this.score = gameState.score; 117 | this.gameOver = gameState.game_over; 118 | this.victory = gameState.victory; 119 | 120 | this.updateDisplay(); 121 | 122 | if (gameState.message) { 123 | this.showMessage(gameState.message); 124 | } 125 | 126 | if (this.gameOver || this.victory) { 127 | this.showGameOverlay(); 128 | } 129 | } 130 | 131 | updateDisplay() { 132 | // Update score 133 | const scoreElement = document.getElementById('score'); 134 | if (scoreElement) { 135 | scoreElement.textContent = this.score.toLocaleString(); 136 | } 137 | 138 | // Update board 139 | for (let row = 0; row < 4; row++) { 140 | for (let col = 0; col < 4; col++) { 141 | const index = row * 4 + col; 142 | const tile = document.getElementById(`tile-${index}`); 143 | const value = this.board[row][col]; 144 | 145 | if (tile) { 146 | tile.textContent = value === 0 ? '' : value; 147 | tile.className = `tile ${value === 0 ? 'tile-empty' : `tile-${value}`}`; 148 | } 149 | } 150 | } 151 | } 152 | 153 | showGameOverlay() { 154 | const overlay = document.getElementById('game-overlay'); 155 | const message = document.getElementById('overlay-message'); 156 | 157 | if (overlay && message) { 158 | if (this.victory) { 159 | message.textContent = '🎉 You Win! You merged two 8192 tiles!'; 160 | overlay.className = 'game-overlay victory'; 161 | } else { 162 | message.textContent = '😔 Game Over! No more moves available.'; 163 | overlay.className = 'game-overlay game-over'; 164 | } 165 | overlay.style.display = 'flex'; 166 | } 167 | } 168 | 169 | hideGameOverlay() { 170 | const overlay = document.getElementById('game-overlay'); 171 | if (overlay) { 172 | overlay.style.display = 'none'; 173 | } 174 | } 175 | 176 | showMessage(message) { 177 | // Create temporary message notification 178 | const notification = document.createElement('div'); 179 | notification.style.cssText = ` 180 | position: fixed; 181 | top: 50%; 182 | left: 50%; 183 | transform: translate(-50%, -50%); 184 | background: rgba(0, 0, 0, 0.8); 185 | color: white; 186 | padding: 20px 30px; 187 | border-radius: 10px; 188 | font-size: 18px; 189 | font-weight: 500; 190 | z-index: 1000; 191 | pointer-events: none; 192 | opacity: 0; 193 | transition: opacity 0.3s ease; 194 | `; 195 | notification.textContent = message; 196 | document.body.appendChild(notification); 197 | 198 | // Animate in 199 | setTimeout(() => { 200 | notification.style.opacity = '1'; 201 | }, 10); 202 | 203 | // Animate out and remove 204 | setTimeout(() => { 205 | notification.style.opacity = '0'; 206 | setTimeout(() => { 207 | document.body.removeChild(notification); 208 | }, 300); 209 | }, 2000); 210 | } 211 | } 212 | 213 | // Global functions for button handlers 214 | function startNewGame() { 215 | if (window.gameWS) { 216 | window.gameWS.send('new_game', {}); 217 | if (window.game) { 218 | window.game.hideGameOverlay(); 219 | } 220 | } 221 | } 222 | 223 | // Initialize game when DOM is loaded 224 | document.addEventListener('DOMContentLoaded', () => { 225 | window.game = new Game2048(); 226 | }); 227 | -------------------------------------------------------------------------------- /backend/cmd/server/static/css/main.css: -------------------------------------------------------------------------------- 1 | /* Main CSS for 2048 Game */ 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 9 | margin: 0; 10 | padding: 0; 11 | background: #faf8ef; 12 | color: #776e65; 13 | line-height: 1.6; 14 | } 15 | 16 | .container { 17 | min-height: 100vh; 18 | } 19 | 20 | /* Game Tiles */ 21 | .tile { 22 | background: rgba(238, 228, 218, 0.35); 23 | border-radius: 6px; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | font-size: 2rem; 28 | font-weight: 700; 29 | transition: all 0.15s ease-in-out; 30 | aspect-ratio: 1; 31 | position: relative; 32 | } 33 | 34 | .tile-empty { 35 | background: rgba(238, 228, 218, 0.35); 36 | } 37 | 38 | .tile-2 { 39 | background: #eee4da; 40 | color: #776e65; 41 | font-size: 1.8rem; 42 | } 43 | 44 | .tile-4 { 45 | background: #ede0c8; 46 | color: #776e65; 47 | font-size: 1.8rem; 48 | } 49 | 50 | .tile-8 { 51 | background: #f2b179; 52 | color: #f9f6f2; 53 | font-size: 1.8rem; 54 | } 55 | 56 | .tile-16 { 57 | background: #f59563; 58 | color: #f9f6f2; 59 | font-size: 1.7rem; 60 | } 61 | 62 | .tile-32 { 63 | background: #f67c5f; 64 | color: #f9f6f2; 65 | font-size: 1.7rem; 66 | } 67 | 68 | .tile-64 { 69 | background: #f65e3b; 70 | color: #f9f6f2; 71 | font-size: 1.6rem; 72 | } 73 | 74 | .tile-128 { 75 | background: #edcf72; 76 | color: #f9f6f2; 77 | font-size: 1.4rem; 78 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.2); 79 | } 80 | 81 | .tile-256 { 82 | background: #edcc61; 83 | color: #f9f6f2; 84 | font-size: 1.4rem; 85 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.3); 86 | } 87 | 88 | .tile-512 { 89 | background: #edc850; 90 | color: #f9f6f2; 91 | font-size: 1.4rem; 92 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.4); 93 | } 94 | 95 | .tile-1024 { 96 | background: #edc53f; 97 | color: #f9f6f2; 98 | font-size: 1.2rem; 99 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.5); 100 | } 101 | 102 | .tile-2048 { 103 | background: #edc22e; 104 | color: #f9f6f2; 105 | font-size: 1.2rem; 106 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.6); 107 | } 108 | 109 | .tile-4096 { 110 | background: #3c3a32; 111 | color: #f9f6f2; 112 | font-size: 1.1rem; 113 | box-shadow: 0 0 30px 10px rgba(60, 58, 50, 0.4); 114 | } 115 | 116 | .tile-8192 { 117 | background: #000000; 118 | color: #f9f6f2; 119 | font-size: 1.1rem; 120 | box-shadow: 0 0 30px 10px rgba(0, 0, 0, 0.6); 121 | } 122 | 123 | .tile-16384 { 124 | background: #ff6b6b; 125 | color: #f9f6f2; 126 | font-size: 1.0rem; 127 | box-shadow: 0 0 40px 15px rgba(255, 107, 107, 0.8); 128 | animation: victory-glow 2s ease-in-out infinite alternate; 129 | } 130 | 131 | @keyframes victory-glow { 132 | from { 133 | box-shadow: 0 0 30px 10px rgba(255, 215, 0, 0.6); 134 | } 135 | to { 136 | box-shadow: 0 0 50px 20px rgba(255, 215, 0, 0.8); 137 | } 138 | } 139 | 140 | /* Leaderboard Styles */ 141 | .leaderboard-list { 142 | display: flex; 143 | flex-direction: column; 144 | gap: 10px; 145 | } 146 | 147 | .leaderboard-entry { 148 | display: flex; 149 | align-items: center; 150 | padding: 12px 15px; 151 | background: #f8f8f8; 152 | border-radius: 8px; 153 | transition: background 0.2s ease; 154 | } 155 | 156 | .leaderboard-entry:hover { 157 | background: #f0f0f0; 158 | } 159 | 160 | .leaderboard-entry.current-user { 161 | background: #e3f2fd; 162 | border: 2px solid #2196f3; 163 | } 164 | 165 | .leaderboard-entry.rank-1 { 166 | background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%); 167 | color: #333; 168 | } 169 | 170 | .leaderboard-entry.rank-2 { 171 | background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%); 172 | color: #333; 173 | } 174 | 175 | .leaderboard-entry.rank-3 { 176 | background: linear-gradient(135deg, #cd7f32 0%, #daa520 100%); 177 | color: #333; 178 | } 179 | 180 | .leaderboard-entry .rank { 181 | font-size: 1.2rem; 182 | font-weight: 700; 183 | min-width: 40px; 184 | text-align: center; 185 | } 186 | 187 | .leaderboard-entry .user-info { 188 | flex: 1; 189 | display: flex; 190 | align-items: center; 191 | gap: 10px; 192 | margin-left: 15px; 193 | } 194 | 195 | .leaderboard-entry .user-avatar { 196 | width: 32px; 197 | height: 32px; 198 | border-radius: 50%; 199 | object-fit: cover; 200 | } 201 | 202 | .leaderboard-entry .user-name { 203 | font-weight: 500; 204 | } 205 | 206 | .leaderboard-entry .you-badge { 207 | background: #2196f3; 208 | color: white; 209 | font-size: 0.7rem; 210 | font-weight: 600; 211 | padding: 2px 6px; 212 | border-radius: 10px; 213 | text-transform: uppercase; 214 | } 215 | 216 | .leaderboard-entry .score { 217 | font-size: 1.1rem; 218 | font-weight: 700; 219 | color: #776e65; 220 | } 221 | 222 | .empty-leaderboard { 223 | text-align: center; 224 | padding: 40px 20px; 225 | color: #8f7a66; 226 | } 227 | 228 | .empty-leaderboard p { 229 | margin: 10px 0; 230 | } 231 | 232 | /* Loading Styles */ 233 | .loading { 234 | display: flex; 235 | flex-direction: column; 236 | align-items: center; 237 | justify-content: center; 238 | padding: 40px 20px; 239 | color: #8f7a66; 240 | } 241 | 242 | .loading-spinner { 243 | width: 32px; 244 | height: 32px; 245 | border: 3px solid #f3f3f3; 246 | border-top: 3px solid #8f7a66; 247 | border-radius: 50%; 248 | animation: spin 1s linear infinite; 249 | margin-bottom: 15px; 250 | } 251 | 252 | @keyframes spin { 253 | 0% { transform: rotate(0deg); } 254 | 100% { transform: rotate(360deg); } 255 | } 256 | 257 | /* Button Styles */ 258 | button { 259 | font-family: inherit; 260 | cursor: pointer; 261 | border: none; 262 | outline: none; 263 | transition: all 0.2s ease; 264 | } 265 | 266 | button:hover { 267 | transform: translateY(-1px); 268 | } 269 | 270 | button:active { 271 | transform: translateY(0); 272 | } 273 | 274 | /* Utility Classes */ 275 | .text-center { 276 | text-align: center; 277 | } 278 | 279 | .mt-1 { margin-top: 0.5rem; } 280 | .mt-2 { margin-top: 1rem; } 281 | .mt-3 { margin-top: 1.5rem; } 282 | .mt-4 { margin-top: 2rem; } 283 | 284 | .mb-1 { margin-bottom: 0.5rem; } 285 | .mb-2 { margin-bottom: 1rem; } 286 | .mb-3 { margin-bottom: 1.5rem; } 287 | .mb-4 { margin-bottom: 2rem; } 288 | 289 | .p-1 { padding: 0.5rem; } 290 | .p-2 { padding: 1rem; } 291 | .p-3 { padding: 1.5rem; } 292 | .p-4 { padding: 2rem; } 293 | 294 | /* Responsive Design */ 295 | @media (max-width: 768px) { 296 | .tile { 297 | font-size: 1.5rem; 298 | } 299 | 300 | .tile-2, .tile-4, .tile-8 { 301 | font-size: 1.3rem; 302 | } 303 | 304 | .tile-16, .tile-32, .tile-64 { 305 | font-size: 1.2rem; 306 | } 307 | 308 | .tile-128, .tile-256, .tile-512 { 309 | font-size: 1rem; 310 | } 311 | 312 | .tile-1024, .tile-2048 { 313 | font-size: 0.9rem; 314 | } 315 | 316 | .tile-4096, .tile-8192 { 317 | font-size: 0.8rem; 318 | } 319 | } 320 | 321 | @media (max-width: 480px) { 322 | .tile { 323 | font-size: 1.2rem; 324 | } 325 | 326 | .tile-2, .tile-4, .tile-8 { 327 | font-size: 1rem; 328 | } 329 | 330 | .tile-16, .tile-32, .tile-64 { 331 | font-size: 0.9rem; 332 | } 333 | 334 | .tile-128, .tile-256, .tile-512 { 335 | font-size: 0.8rem; 336 | } 337 | 338 | .tile-1024, .tile-2048 { 339 | font-size: 0.7rem; 340 | } 341 | 342 | .tile-4096, .tile-8192 { 343 | font-size: 0.6rem; 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /backend/internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | // Config holds all configuration for the application 14 | type Config struct { 15 | Server ServerConfig 16 | Database DatabaseConfig 17 | Redis RedisConfig 18 | OAuth2 OAuth2Config 19 | Game GameConfig 20 | Leaderboard LeaderboardConfig 21 | } 22 | 23 | // ServerConfig holds server-related configuration 24 | type ServerConfig struct { 25 | Host string 26 | Port string 27 | JWTSecret string 28 | GinMode string 29 | StaticFilesEmbedded bool 30 | EnableMetrics bool 31 | EnableHealthCheck bool 32 | CORSOrigins []string 33 | Debug bool 34 | LogLevel string 35 | } 36 | 37 | // DatabaseConfig holds database-related configuration 38 | type DatabaseConfig struct { 39 | Host string 40 | Port string 41 | Name string 42 | User string 43 | Password string 44 | SSLMode string 45 | } 46 | 47 | // RedisConfig holds Redis-related configuration 48 | type RedisConfig struct { 49 | Host string 50 | Port string 51 | Password string 52 | DB int 53 | } 54 | 55 | // OAuth2Config holds OAuth2-related configuration 56 | type OAuth2Config struct { 57 | Provider string 58 | ClientID string 59 | ClientSecret string 60 | RedirectURL string 61 | 62 | // Custom OAuth2 endpoints 63 | AuthURL string 64 | TokenURL string 65 | UserInfoURL string 66 | Scopes []string 67 | 68 | // User info field mappings 69 | UserIDField string 70 | UserEmailField string 71 | UserNameField string 72 | UserAvatarField string 73 | } 74 | 75 | // GameConfig holds game-related configuration 76 | type GameConfig struct { 77 | VictoryTile int 78 | MaxConcurrentGames int 79 | GameSessionTimeout int 80 | } 81 | 82 | // LeaderboardConfig holds leaderboard-related configuration 83 | type LeaderboardConfig struct { 84 | CacheTTL int 85 | MaxEntries int 86 | } 87 | 88 | // Load loads configuration from environment variables 89 | func Load() (*Config, error) { 90 | // Try to load .env file from multiple possible locations 91 | envPaths := []string{ 92 | ".env", // Current directory 93 | "../.env", // Parent directory (for backend/ subdirectory) 94 | "../../.env", // Two levels up (for deeper nesting) 95 | } 96 | 97 | envLoaded := false 98 | for _, path := range envPaths { 99 | if err := godotenv.Load(path); err == nil { 100 | log.Printf("Loaded environment variables from: %s", path) 101 | envLoaded = true 102 | break 103 | } 104 | } 105 | 106 | if !envLoaded { 107 | log.Println("No .env file found, using environment variables and defaults") 108 | } 109 | 110 | config := &Config{ 111 | Server: ServerConfig{ 112 | Host: getEnv("SERVER_HOST", "0.0.0.0"), 113 | Port: getEnv("SERVER_PORT", "6060"), 114 | JWTSecret: getEnv("JWT_SECRET", "your-super-secret-jwt-key"), 115 | GinMode: getEnv("GIN_MODE", "release"), 116 | StaticFilesEmbedded: getEnvBool("STATIC_FILES_EMBEDDED", true), 117 | EnableMetrics: getEnvBool("ENABLE_METRICS", true), 118 | EnableHealthCheck: getEnvBool("ENABLE_HEALTH_CHECK", true), 119 | CORSOrigins: getEnvSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:6060"}), 120 | Debug: getEnvBool("DEBUG", false), 121 | LogLevel: getEnv("LOG_LEVEL", "info"), 122 | }, 123 | Database: DatabaseConfig{ 124 | Host: getEnv("DB_HOST", "localhost"), 125 | Port: getEnv("DB_PORT", "5432"), 126 | Name: getEnv("DB_NAME", "game2048"), 127 | User: getEnv("DB_USER", "postgres"), 128 | Password: getEnv("DB_PASSWORD", "password"), 129 | SSLMode: getEnv("DB_SSL_MODE", "disable"), 130 | }, 131 | Redis: RedisConfig{ 132 | Host: getEnv("REDIS_HOST", "localhost"), 133 | Port: getEnv("REDIS_PORT", "6379"), 134 | Password: getEnv("REDIS_PASSWORD", ""), 135 | DB: getEnvInt("REDIS_DB", 0), 136 | }, 137 | OAuth2: OAuth2Config{ 138 | Provider: getEnv("OAUTH2_PROVIDER", "custom"), 139 | ClientID: getEnv("OAUTH2_CLIENT_ID", ""), 140 | ClientSecret: getEnv("OAUTH2_CLIENT_SECRET", ""), 141 | RedirectURL: getEnv("OAUTH2_REDIRECT_URL", "http://localhost:6060/auth/callback"), 142 | 143 | // Custom OAuth2 endpoints 144 | AuthURL: getEnv("OAUTH2_AUTH_URL", ""), 145 | TokenURL: getEnv("OAUTH2_TOKEN_URL", ""), 146 | UserInfoURL: getEnv("OAUTH2_USERINFO_URL", ""), 147 | Scopes: getEnvSlice("OAUTH2_SCOPES", []string{"openid", "profile", "email"}), 148 | 149 | // User info field mappings 150 | UserIDField: getEnv("OAUTH2_USER_ID_FIELD", "id"), 151 | UserEmailField: getEnv("OAUTH2_USER_EMAIL_FIELD", "email"), 152 | UserNameField: getEnv("OAUTH2_USER_NAME_FIELD", "name"), 153 | UserAvatarField: getEnv("OAUTH2_USER_AVATAR_FIELD", "avatar"), 154 | }, 155 | Game: GameConfig{ 156 | VictoryTile: getEnvInt("VICTORY_TILE", 16384), // Two 8192 tiles merged 157 | MaxConcurrentGames: getEnvInt("MAX_CONCURRENT_GAMES", 1000), 158 | GameSessionTimeout: getEnvInt("GAME_SESSION_TIMEOUT", 3600), 159 | }, 160 | Leaderboard: LeaderboardConfig{ 161 | CacheTTL: getEnvInt("LEADERBOARD_CACHE_TTL", 300), 162 | MaxEntries: getEnvInt("MAX_LEADERBOARD_ENTRIES", 100), 163 | }, 164 | } 165 | 166 | // Validate required configuration 167 | if err := config.Validate(); err != nil { 168 | return nil, fmt.Errorf("configuration validation failed: %w", err) 169 | } 170 | 171 | return config, nil 172 | } 173 | 174 | // Validate validates the configuration 175 | func (c *Config) Validate() error { 176 | if c.Server.JWTSecret == "" || c.Server.JWTSecret == "your-super-secret-jwt-key" || c.Server.JWTSecret == "your-super-secret-jwt-key-change-this-in-production" { 177 | return fmt.Errorf("JWT_SECRET must be set to a secure value") 178 | } 179 | 180 | if c.OAuth2.ClientID == "" || c.OAuth2.ClientSecret == "" { 181 | return fmt.Errorf("OAuth2 client ID and secret must be set") 182 | } 183 | 184 | if c.Database.Host == "" || c.Database.Name == "" || c.Database.User == "" { 185 | return fmt.Errorf("database configuration is incomplete") 186 | } 187 | 188 | if c.Game.VictoryTile <= 0 { 189 | return fmt.Errorf("victory tile must be positive") 190 | } 191 | 192 | return nil 193 | } 194 | 195 | // GetDatabaseURL returns the database connection URL 196 | func (c *Config) GetDatabaseURL() string { 197 | return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", 198 | c.Database.Host, c.Database.Port, c.Database.User, 199 | c.Database.Password, c.Database.Name, c.Database.SSLMode) 200 | } 201 | 202 | // GetRedisURL returns the Redis connection URL 203 | func (c *Config) GetRedisURL() string { 204 | if c.Redis.Password != "" { 205 | return fmt.Sprintf("redis://:%s@%s:%s/%d", 206 | c.Redis.Password, c.Redis.Host, c.Redis.Port, c.Redis.DB) 207 | } 208 | return fmt.Sprintf("redis://%s:%s/%d", 209 | c.Redis.Host, c.Redis.Port, c.Redis.DB) 210 | } 211 | 212 | // GetServerAddress returns the server address 213 | func (c *Config) GetServerAddress() string { 214 | return fmt.Sprintf("%s:%s", c.Server.Host, c.Server.Port) 215 | } 216 | 217 | // Helper functions for environment variable parsing 218 | 219 | func getEnv(key, defaultValue string) string { 220 | if value := os.Getenv(key); value != "" { 221 | return value 222 | } 223 | return defaultValue 224 | } 225 | 226 | func getEnvInt(key string, defaultValue int) int { 227 | if value := os.Getenv(key); value != "" { 228 | if intValue, err := strconv.Atoi(value); err == nil { 229 | return intValue 230 | } 231 | } 232 | return defaultValue 233 | } 234 | 235 | func getEnvBool(key string, defaultValue bool) bool { 236 | if value := os.Getenv(key); value != "" { 237 | if boolValue, err := strconv.ParseBool(value); err == nil { 238 | return boolValue 239 | } 240 | } 241 | return defaultValue 242 | } 243 | 244 | func getEnvSlice(key string, defaultValue []string) []string { 245 | if value := os.Getenv(key); value != "" { 246 | return strings.Split(value, ",") 247 | } 248 | return defaultValue 249 | } 250 | -------------------------------------------------------------------------------- /backend/internal/database/gorm.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "game2048/pkg/models" 9 | 10 | "gorm.io/driver/postgres" 11 | "gorm.io/gorm" 12 | "gorm.io/gorm/logger" 13 | ) 14 | 15 | // GormDB wraps the GORM database connection and implements Database interface 16 | type GormDB struct { 17 | db *gorm.DB 18 | } 19 | 20 | // Ensure GormDB implements Database interface 21 | var _ Database = (*GormDB)(nil) 22 | 23 | // NewGormDB creates a new GORM database connection 24 | func NewGormDB(host, port, user, password, dbname, sslmode string) (*GormDB, error) { 25 | dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", 26 | host, port, user, password, dbname, sslmode) 27 | 28 | // Configure GORM logger 29 | gormLogger := logger.New( 30 | log.New(log.Writer(), "\r\n", log.LstdFlags), 31 | logger.Config{ 32 | SlowThreshold: time.Second, 33 | LogLevel: logger.Info, 34 | IgnoreRecordNotFoundError: true, 35 | Colorful: true, 36 | }, 37 | ) 38 | 39 | db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ 40 | Logger: gormLogger, 41 | }) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to connect to database: %w", err) 44 | } 45 | 46 | // Get underlying sql.DB to configure connection pool 47 | sqlDB, err := db.DB() 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err) 50 | } 51 | 52 | // Set connection pool settings 53 | sqlDB.SetMaxOpenConns(25) 54 | sqlDB.SetMaxIdleConns(5) 55 | sqlDB.SetConnMaxLifetime(5 * time.Minute) 56 | 57 | // Test the connection 58 | if err := sqlDB.Ping(); err != nil { 59 | return nil, fmt.Errorf("failed to ping database: %w", err) 60 | } 61 | 62 | log.Println("Successfully connected to PostgreSQL database with GORM") 63 | 64 | gormDB := &GormDB{db: db} 65 | 66 | // Auto-migrate the schema 67 | if err := gormDB.AutoMigrate(); err != nil { 68 | return nil, fmt.Errorf("failed to auto-migrate: %w", err) 69 | } 70 | 71 | return gormDB, nil 72 | } 73 | 74 | // AutoMigrate runs database migrations 75 | func (g *GormDB) AutoMigrate() error { 76 | return g.db.AutoMigrate( 77 | &models.GormUser{}, 78 | &models.GormGame{}, 79 | &models.GormDailyLeaderboard{}, 80 | &models.GormWeeklyLeaderboard{}, 81 | &models.GormMonthlyLeaderboard{}, 82 | ) 83 | } 84 | 85 | // Close closes the database connection 86 | func (g *GormDB) Close() error { 87 | sqlDB, err := g.db.DB() 88 | if err != nil { 89 | return err 90 | } 91 | return sqlDB.Close() 92 | } 93 | 94 | // CreateUser creates a new user 95 | func (g *GormDB) CreateUser(user *models.User) error { 96 | gormUser := &models.GormUser{} 97 | gormUser.FromUser(user) 98 | 99 | // Use GORM's upsert functionality 100 | result := g.db.Where("provider = ? AND provider_id = ?", user.Provider, user.ProviderID). 101 | Assign(models.GormUser{ 102 | Email: user.Email, 103 | Name: user.Name, 104 | Avatar: user.Avatar, 105 | UpdatedAt: time.Now(), 106 | }). 107 | FirstOrCreate(gormUser) 108 | 109 | if result.Error != nil { 110 | return fmt.Errorf("failed to create user: %w", result.Error) 111 | } 112 | 113 | // Update the original user with the database values 114 | *user = *gormUser.ToUser() 115 | return nil 116 | } 117 | 118 | // GetUser retrieves a user by ID 119 | func (g *GormDB) GetUser(userID string) (*models.User, error) { 120 | var gormUser models.GormUser 121 | result := g.db.Where("id = ?", userID).First(&gormUser) 122 | if result.Error != nil { 123 | if result.Error == gorm.ErrRecordNotFound { 124 | return nil, fmt.Errorf("user not found") 125 | } 126 | return nil, fmt.Errorf("failed to get user: %w", result.Error) 127 | } 128 | 129 | return gormUser.ToUser(), nil 130 | } 131 | 132 | // GetUserByProvider retrieves a user by provider and provider ID 133 | func (g *GormDB) GetUserByProvider(provider, providerID string) (*models.User, error) { 134 | var gormUser models.GormUser 135 | result := g.db.Where("provider = ? AND provider_id = ?", provider, providerID).First(&gormUser) 136 | if result.Error != nil { 137 | if result.Error == gorm.ErrRecordNotFound { 138 | return nil, fmt.Errorf("user not found") 139 | } 140 | return nil, fmt.Errorf("failed to get user by provider: %w", result.Error) 141 | } 142 | 143 | return gormUser.ToUser(), nil 144 | } 145 | 146 | // CreateGame creates a new game 147 | func (g *GormDB) CreateGame(game *models.GameState) error { 148 | gormGame := &models.GormGame{} 149 | gormGame.FromGameState(game) 150 | 151 | result := g.db.Create(gormGame) 152 | if result.Error != nil { 153 | return fmt.Errorf("failed to create game: %w", result.Error) 154 | } 155 | 156 | // Update the original game with the database values 157 | *game = *gormGame.ToGameState() 158 | return nil 159 | } 160 | 161 | // UpdateGame updates an existing game 162 | func (g *GormDB) UpdateGame(game *models.GameState) error { 163 | gormGame := &models.GormGame{} 164 | gormGame.FromGameState(game) 165 | 166 | result := g.db.Model(&models.GormGame{}). 167 | Where("id = ? AND user_id = ?", game.ID, game.UserID). 168 | Updates(map[string]interface{}{ 169 | "board": gormGame.Board, 170 | "score": game.Score, 171 | "game_over": game.GameOver, 172 | "victory": game.Victory, 173 | "updated_at": time.Now(), 174 | }) 175 | 176 | if result.Error != nil { 177 | return fmt.Errorf("failed to update game: %w", result.Error) 178 | } 179 | 180 | if result.RowsAffected == 0 { 181 | return fmt.Errorf("game not found or not owned by user") 182 | } 183 | 184 | game.UpdatedAt = time.Now() 185 | return nil 186 | } 187 | 188 | // GetGame retrieves a game by ID and user ID 189 | func (g *GormDB) GetGame(gameID, userID string) (*models.GameState, error) { 190 | var gormGame models.GormGame 191 | result := g.db.Where("id = ? AND user_id = ?", gameID, userID).First(&gormGame) 192 | if result.Error != nil { 193 | if result.Error == gorm.ErrRecordNotFound { 194 | return nil, fmt.Errorf("game not found") 195 | } 196 | return nil, fmt.Errorf("failed to get game: %w", result.Error) 197 | } 198 | 199 | return gormGame.ToGameState(), nil 200 | } 201 | 202 | // GetUserActiveGame retrieves the user's active (non-finished) game 203 | func (g *GormDB) GetUserActiveGame(userID string) (*models.GameState, error) { 204 | var gormGame models.GormGame 205 | result := g.db.Where("user_id = ? AND game_over = ? AND victory = ?", userID, false, false). 206 | Order("updated_at DESC"). 207 | First(&gormGame) 208 | 209 | if result.Error != nil { 210 | if result.Error == gorm.ErrRecordNotFound { 211 | return nil, nil // No active game found 212 | } 213 | return nil, fmt.Errorf("failed to get active game: %w", result.Error) 214 | } 215 | 216 | return gormGame.ToGameState(), nil 217 | } 218 | 219 | // GetLeaderboard retrieves leaderboard entries 220 | func (g *GormDB) GetLeaderboard(leaderboardType models.LeaderboardType, limit int) ([]models.LeaderboardEntry, error) { 221 | var entries []models.GormLeaderboardEntry 222 | 223 | // Build subquery to get max score per user 224 | subquery := g.db.Table("games"). 225 | Select("user_id, MAX(score) as max_score"). 226 | Where("game_over = ? OR victory = ?", true, true) 227 | 228 | switch leaderboardType { 229 | case models.LeaderboardDaily: 230 | subquery = subquery.Where("created_at >= CURRENT_DATE") 231 | case models.LeaderboardWeekly: 232 | subquery = subquery.Where("created_at >= DATE_TRUNC('week', CURRENT_DATE)") 233 | case models.LeaderboardMonthly: 234 | subquery = subquery.Where("created_at >= DATE_TRUNC('month', CURRENT_DATE)") 235 | case models.LeaderboardAll: 236 | // No additional filter for all-time leaderboard 237 | default: 238 | return nil, fmt.Errorf("invalid leaderboard type") 239 | } 240 | 241 | subquery = subquery.Group("user_id") 242 | 243 | // Main query to get full game details for the max score games 244 | query := g.db.Table("games g"). 245 | Select("g.user_id, u.name as user_name, u.avatar as user_avatar, g.score, g.id as game_id, g.created_at, ROW_NUMBER() OVER (ORDER BY g.score DESC) as rank"). 246 | Joins("JOIN users u ON g.user_id = u.id"). 247 | Joins("JOIN (?) max_scores ON g.user_id = max_scores.user_id AND g.score = max_scores.max_score", subquery). 248 | Where("g.game_over = ? OR g.victory = ?", true, true). 249 | Order("g.score DESC"). 250 | Limit(limit) 251 | 252 | result := query.Scan(&entries) 253 | if result.Error != nil { 254 | return nil, fmt.Errorf("failed to query leaderboard: %w", result.Error) 255 | } 256 | 257 | // Convert to regular LeaderboardEntry 258 | var leaderboardEntries []models.LeaderboardEntry 259 | for _, entry := range entries { 260 | leaderboardEntries = append(leaderboardEntries, *entry.ToLeaderboardEntry()) 261 | } 262 | 263 | return leaderboardEntries, nil 264 | } 265 | 266 | // GetDB returns the underlying GORM database instance 267 | func (g *GormDB) GetDB() *gorm.DB { 268 | return g.db 269 | } 270 | -------------------------------------------------------------------------------- /backend/internal/websocket/hub.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "sync" 8 | "time" 9 | 10 | "game2048/internal/auth" 11 | "game2048/internal/cache" 12 | "game2048/internal/database" 13 | "game2048/internal/game" 14 | "game2048/pkg/models" 15 | 16 | "github.com/gin-gonic/gin" 17 | "github.com/google/uuid" 18 | "github.com/gorilla/websocket" 19 | ) 20 | 21 | // Hub maintains the set of active clients and broadcasts messages to the clients 22 | type Hub struct { 23 | // Registered clients 24 | clients map[*Client]bool 25 | 26 | // Inbound messages from the clients 27 | broadcast chan []byte 28 | 29 | // Register requests from the clients 30 | register chan *Client 31 | 32 | // Unregister requests from clients 33 | unregister chan *Client 34 | 35 | // Game engine 36 | gameEngine *game.Engine 37 | 38 | // Database 39 | db database.Database 40 | 41 | // Cache for game sessions 42 | cache cache.Cache 43 | 44 | // Auth service 45 | authService *auth.AuthService 46 | 47 | // Mutex for thread safety 48 | mutex sync.RWMutex 49 | } 50 | 51 | // Client represents a WebSocket client 52 | type Client struct { 53 | // The websocket connection 54 | conn *websocket.Conn 55 | 56 | // Buffered channel of outbound messages 57 | send chan []byte 58 | 59 | // User ID 60 | userID string 61 | 62 | // Current game ID 63 | gameID uuid.UUID 64 | 65 | // Hub reference 66 | hub *Hub 67 | } 68 | 69 | // WebSocket upgrader 70 | var upgrader = websocket.Upgrader{ 71 | ReadBufferSize: 1024, 72 | WriteBufferSize: 1024, 73 | CheckOrigin: func(r *http.Request) bool { 74 | // Allow connections from any origin in development 75 | // In production, you should check the origin properly 76 | return true 77 | }, 78 | } 79 | 80 | // NewHub creates a new WebSocket hub 81 | func NewHub(gameEngine *game.Engine, db database.Database, authService *auth.AuthService, redisCache cache.Cache) *Hub { 82 | return &Hub{ 83 | clients: make(map[*Client]bool), 84 | broadcast: make(chan []byte), 85 | register: make(chan *Client), 86 | unregister: make(chan *Client), 87 | gameEngine: gameEngine, 88 | db: db, 89 | cache: redisCache, 90 | authService: authService, 91 | } 92 | } 93 | 94 | // Run starts the hub 95 | func (h *Hub) Run() { 96 | for { 97 | select { 98 | case client := <-h.register: 99 | h.mutex.Lock() 100 | h.clients[client] = true 101 | h.mutex.Unlock() 102 | log.Printf("Client connected: %s", client.userID) 103 | 104 | // Send current game state if user has an active game 105 | go h.sendCurrentGameState(client) 106 | 107 | case client := <-h.unregister: 108 | h.mutex.Lock() 109 | if _, ok := h.clients[client]; ok { 110 | delete(h.clients, client) 111 | close(client.send) 112 | log.Printf("Client disconnected: %s", client.userID) 113 | } 114 | h.mutex.Unlock() 115 | 116 | case message := <-h.broadcast: 117 | h.mutex.RLock() 118 | for client := range h.clients { 119 | select { 120 | case client.send <- message: 121 | default: 122 | close(client.send) 123 | delete(h.clients, client) 124 | } 125 | } 126 | h.mutex.RUnlock() 127 | } 128 | } 129 | } 130 | 131 | // HandleWebSocket handles WebSocket connections 132 | func (h *Hub) HandleWebSocket(c *gin.Context) { 133 | // Get JWT token from query parameter or header 134 | token := c.Query("token") 135 | if token == "" { 136 | token = c.GetHeader("Authorization") 137 | if len(token) > 7 && token[:7] == "Bearer " { 138 | token = token[7:] 139 | } 140 | } 141 | 142 | if token == "" { 143 | c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing authentication token"}) 144 | return 145 | } 146 | 147 | // Validate JWT token 148 | userID, err := h.authService.ValidateJWT(token) 149 | if err != nil { 150 | c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authentication token"}) 151 | return 152 | } 153 | 154 | // Upgrade HTTP connection to WebSocket 155 | conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) 156 | if err != nil { 157 | log.Printf("Failed to upgrade connection: %v", err) 158 | return 159 | } 160 | 161 | // Create new client 162 | client := &Client{ 163 | conn: conn, 164 | send: make(chan []byte, 256), 165 | userID: userID, 166 | hub: h, 167 | } 168 | 169 | // Register client 170 | h.register <- client 171 | 172 | // Start goroutines for reading and writing 173 | go client.writePump() 174 | go client.readPump() 175 | } 176 | 177 | // sendCurrentGameState sends the current game state to a newly connected client 178 | func (h *Hub) sendCurrentGameState(client *Client) { 179 | // Try to get game state from Redis cache first 180 | var gameState *models.GameState 181 | var err error 182 | 183 | if h.cache != nil { 184 | gameState, err = h.cache.GetGameSession(client.userID) 185 | if err != nil { 186 | // Cache miss or error, try database as fallback 187 | gameState, err = h.db.GetUserActiveGame(client.userID) 188 | if err != nil { 189 | log.Printf("Error getting active game for user %s: %v", client.userID, err) 190 | return 191 | } 192 | 193 | // If found in database, cache it for future use 194 | if gameState != nil && h.cache != nil { 195 | // Cache for 1 hour 196 | if err := h.cache.SetGameSession(client.userID, gameState, time.Hour); err != nil { 197 | log.Printf("Failed to cache game session: %v", err) 198 | } 199 | } 200 | } 201 | } else { 202 | // No cache available, use database 203 | gameState, err = h.db.GetUserActiveGame(client.userID) 204 | if err != nil { 205 | log.Printf("Error getting active game for user %s: %v", client.userID, err) 206 | return 207 | } 208 | } 209 | 210 | if gameState != nil { 211 | client.gameID = gameState.ID 212 | response := models.GameResponse{ 213 | Board: gameState.Board, 214 | Score: gameState.Score, 215 | GameOver: gameState.GameOver, 216 | Victory: gameState.Victory, 217 | } 218 | 219 | message := models.WebSocketMessage{ 220 | Type: "game_state", 221 | Data: response, 222 | } 223 | 224 | client.sendMessage(message) 225 | } 226 | } 227 | 228 | // sendMessage sends a message to the client 229 | func (c *Client) sendMessage(message models.WebSocketMessage) { 230 | data, err := json.Marshal(message) 231 | if err != nil { 232 | log.Printf("Error marshaling message: %v", err) 233 | return 234 | } 235 | 236 | select { 237 | case c.send <- data: 238 | default: 239 | close(c.send) 240 | c.hub.mutex.Lock() 241 | delete(c.hub.clients, c) 242 | c.hub.mutex.Unlock() 243 | } 244 | } 245 | 246 | // readPump pumps messages from the websocket connection to the hub 247 | func (c *Client) readPump() { 248 | defer func() { 249 | c.hub.unregister <- c 250 | c.conn.Close() 251 | }() 252 | 253 | // Set read deadline and pong handler 254 | c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) 255 | c.conn.SetPongHandler(func(string) error { 256 | c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) 257 | return nil 258 | }) 259 | 260 | for { 261 | _, messageBytes, err := c.conn.ReadMessage() 262 | if err != nil { 263 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 264 | log.Printf("WebSocket error: %v", err) 265 | } 266 | break 267 | } 268 | 269 | // Parse message 270 | var message models.WebSocketMessage 271 | if err := json.Unmarshal(messageBytes, &message); err != nil { 272 | log.Printf("Error parsing message: %v", err) 273 | c.sendError("Invalid message format") 274 | continue 275 | } 276 | 277 | // Handle message 278 | c.handleMessage(message) 279 | } 280 | } 281 | 282 | // writePump pumps messages from the hub to the websocket connection 283 | func (c *Client) writePump() { 284 | ticker := time.NewTicker(54 * time.Second) 285 | defer func() { 286 | ticker.Stop() 287 | c.conn.Close() 288 | }() 289 | 290 | for { 291 | select { 292 | case message, ok := <-c.send: 293 | c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) 294 | if !ok { 295 | c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 296 | return 297 | } 298 | 299 | w, err := c.conn.NextWriter(websocket.TextMessage) 300 | if err != nil { 301 | return 302 | } 303 | w.Write(message) 304 | 305 | // Add queued messages to the current message 306 | n := len(c.send) 307 | for i := 0; i < n; i++ { 308 | w.Write([]byte{'\n'}) 309 | w.Write(<-c.send) 310 | } 311 | 312 | if err := w.Close(); err != nil { 313 | return 314 | } 315 | 316 | case <-ticker.C: 317 | c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) 318 | if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { 319 | return 320 | } 321 | } 322 | } 323 | } 324 | 325 | // handleMessage handles incoming WebSocket messages 326 | func (c *Client) handleMessage(message models.WebSocketMessage) { 327 | switch message.Type { 328 | case "move": 329 | c.handleMove(message.Data) 330 | case "new_game": 331 | c.handleNewGame(message.Data) 332 | case "get_leaderboard": 333 | c.handleGetLeaderboard(message.Data) 334 | default: 335 | c.sendError("Unknown message type") 336 | } 337 | } 338 | 339 | // sendError sends an error message to the client 340 | func (c *Client) sendError(errorMessage string) { 341 | response := models.ErrorResponse{ 342 | Message: errorMessage, 343 | } 344 | 345 | message := models.WebSocketMessage{ 346 | Type: "error", 347 | Data: response, 348 | } 349 | 350 | c.sendMessage(message) 351 | } 352 | -------------------------------------------------------------------------------- /backend/internal/database/postgres.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "game2048/pkg/models" 11 | 12 | _ "github.com/lib/pq" 13 | ) 14 | 15 | // PostgresDB wraps the database connection and implements Database interface 16 | type PostgresDB struct { 17 | db *sql.DB 18 | } 19 | 20 | // Ensure PostgresDB implements Database interface 21 | var _ Database = (*PostgresDB)(nil) 22 | 23 | // NewPostgresDB creates a new PostgreSQL database connection 24 | func NewPostgresDB(host, port, user, password, dbname, sslmode string) (*PostgresDB, error) { 25 | psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", 26 | host, port, user, password, dbname, sslmode) 27 | 28 | db, err := sql.Open("postgres", psqlInfo) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to open database: %w", err) 31 | } 32 | 33 | // Test the connection 34 | if err := db.Ping(); err != nil { 35 | return nil, fmt.Errorf("failed to ping database: %w", err) 36 | } 37 | 38 | // Set connection pool settings 39 | db.SetMaxOpenConns(25) 40 | db.SetMaxIdleConns(5) 41 | db.SetConnMaxLifetime(5 * time.Minute) 42 | 43 | log.Println("Successfully connected to PostgreSQL database") 44 | 45 | return &PostgresDB{db: db}, nil 46 | } 47 | 48 | // Close closes the database connection 49 | func (p *PostgresDB) Close() error { 50 | return p.db.Close() 51 | } 52 | 53 | // CreateUser creates a new user 54 | func (p *PostgresDB) CreateUser(user *models.User) error { 55 | query := ` 56 | INSERT INTO users (id, email, name, avatar, provider, provider_id, created_at, updated_at) 57 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 58 | ON CONFLICT (provider, provider_id) 59 | DO UPDATE SET 60 | email = EXCLUDED.email, 61 | name = EXCLUDED.name, 62 | avatar = EXCLUDED.avatar, 63 | updated_at = EXCLUDED.updated_at 64 | RETURNING id, created_at, updated_at` 65 | 66 | now := time.Now() 67 | user.CreatedAt = now 68 | user.UpdatedAt = now 69 | 70 | err := p.db.QueryRow(query, user.ID, user.Email, user.Name, user.Avatar, 71 | user.Provider, user.ProviderID, user.CreatedAt, user.UpdatedAt). 72 | Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) 73 | 74 | if err != nil { 75 | return fmt.Errorf("failed to create user: %w", err) 76 | } 77 | 78 | return nil 79 | } 80 | 81 | // GetUser retrieves a user by ID 82 | func (p *PostgresDB) GetUser(userID string) (*models.User, error) { 83 | query := ` 84 | SELECT id, email, name, avatar, provider, provider_id, created_at, updated_at 85 | FROM users WHERE id = $1` 86 | 87 | user := &models.User{} 88 | err := p.db.QueryRow(query, userID).Scan( 89 | &user.ID, &user.Email, &user.Name, &user.Avatar, 90 | &user.Provider, &user.ProviderID, &user.CreatedAt, &user.UpdatedAt) 91 | 92 | if err != nil { 93 | if err == sql.ErrNoRows { 94 | return nil, fmt.Errorf("user not found") 95 | } 96 | return nil, fmt.Errorf("failed to get user: %w", err) 97 | } 98 | 99 | return user, nil 100 | } 101 | 102 | // GetUserByProvider retrieves a user by provider and provider ID 103 | func (p *PostgresDB) GetUserByProvider(provider, providerID string) (*models.User, error) { 104 | query := ` 105 | SELECT id, email, name, avatar, provider, provider_id, created_at, updated_at 106 | FROM users WHERE provider = $1 AND provider_id = $2` 107 | 108 | user := &models.User{} 109 | err := p.db.QueryRow(query, provider, providerID).Scan( 110 | &user.ID, &user.Email, &user.Name, &user.Avatar, 111 | &user.Provider, &user.ProviderID, &user.CreatedAt, &user.UpdatedAt) 112 | 113 | if err != nil { 114 | if err == sql.ErrNoRows { 115 | return nil, fmt.Errorf("user not found") 116 | } 117 | return nil, fmt.Errorf("failed to get user by provider: %w", err) 118 | } 119 | 120 | return user, nil 121 | } 122 | 123 | // CreateGame creates a new game 124 | func (p *PostgresDB) CreateGame(game *models.GameState) error { 125 | boardJSON, err := json.Marshal(game.Board) 126 | if err != nil { 127 | return fmt.Errorf("failed to marshal board: %w", err) 128 | } 129 | 130 | query := ` 131 | INSERT INTO games (id, user_id, board, score, game_over, victory, created_at, updated_at) 132 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` 133 | 134 | now := time.Now() 135 | game.CreatedAt = now 136 | game.UpdatedAt = now 137 | 138 | _, err = p.db.Exec(query, game.ID, game.UserID, boardJSON, game.Score, 139 | game.GameOver, game.Victory, game.CreatedAt, game.UpdatedAt) 140 | 141 | if err != nil { 142 | return fmt.Errorf("failed to create game: %w", err) 143 | } 144 | 145 | return nil 146 | } 147 | 148 | // UpdateGame updates an existing game 149 | func (p *PostgresDB) UpdateGame(game *models.GameState) error { 150 | boardJSON, err := json.Marshal(game.Board) 151 | if err != nil { 152 | return fmt.Errorf("failed to marshal board: %w", err) 153 | } 154 | 155 | query := ` 156 | UPDATE games 157 | SET board = $1, score = $2, game_over = $3, victory = $4, updated_at = $5 158 | WHERE id = $6 AND user_id = $7` 159 | 160 | game.UpdatedAt = time.Now() 161 | 162 | result, err := p.db.Exec(query, boardJSON, game.Score, game.GameOver, 163 | game.Victory, game.UpdatedAt, game.ID, game.UserID) 164 | 165 | if err != nil { 166 | return fmt.Errorf("failed to update game: %w", err) 167 | } 168 | 169 | rowsAffected, err := result.RowsAffected() 170 | if err != nil { 171 | return fmt.Errorf("failed to get rows affected: %w", err) 172 | } 173 | 174 | if rowsAffected == 0 { 175 | return fmt.Errorf("game not found or not owned by user") 176 | } 177 | 178 | return nil 179 | } 180 | 181 | // GetGame retrieves a game by ID and user ID 182 | func (p *PostgresDB) GetGame(gameID, userID string) (*models.GameState, error) { 183 | query := ` 184 | SELECT id, user_id, board, score, game_over, victory, created_at, updated_at 185 | FROM games WHERE id = $1 AND user_id = $2` 186 | 187 | game := &models.GameState{} 188 | var boardJSON []byte 189 | 190 | err := p.db.QueryRow(query, gameID, userID).Scan( 191 | &game.ID, &game.UserID, &boardJSON, &game.Score, 192 | &game.GameOver, &game.Victory, &game.CreatedAt, &game.UpdatedAt) 193 | 194 | if err != nil { 195 | if err == sql.ErrNoRows { 196 | return nil, fmt.Errorf("game not found") 197 | } 198 | return nil, fmt.Errorf("failed to get game: %w", err) 199 | } 200 | 201 | if err := json.Unmarshal(boardJSON, &game.Board); err != nil { 202 | return nil, fmt.Errorf("failed to unmarshal board: %w", err) 203 | } 204 | 205 | return game, nil 206 | } 207 | 208 | // GetUserActiveGame retrieves the user's active (non-finished) game 209 | func (p *PostgresDB) GetUserActiveGame(userID string) (*models.GameState, error) { 210 | query := ` 211 | SELECT id, user_id, board, score, game_over, victory, created_at, updated_at 212 | FROM games 213 | WHERE user_id = $1 AND game_over = false AND victory = false 214 | ORDER BY updated_at DESC 215 | LIMIT 1` 216 | 217 | game := &models.GameState{} 218 | var boardJSON []byte 219 | 220 | err := p.db.QueryRow(query, userID).Scan( 221 | &game.ID, &game.UserID, &boardJSON, &game.Score, 222 | &game.GameOver, &game.Victory, &game.CreatedAt, &game.UpdatedAt) 223 | 224 | if err != nil { 225 | if err == sql.ErrNoRows { 226 | return nil, nil // No active game found 227 | } 228 | return nil, fmt.Errorf("failed to get active game: %w", err) 229 | } 230 | 231 | if err := json.Unmarshal(boardJSON, &game.Board); err != nil { 232 | return nil, fmt.Errorf("failed to unmarshal board: %w", err) 233 | } 234 | 235 | return game, nil 236 | } 237 | 238 | // GetLeaderboard retrieves leaderboard entries 239 | func (p *PostgresDB) GetLeaderboard(leaderboardType models.LeaderboardType, limit int) ([]models.LeaderboardEntry, error) { 240 | var query string 241 | var args []interface{} 242 | 243 | baseQuery := ` 244 | SELECT 245 | g.user_id, 246 | u.name as user_name, 247 | u.avatar as user_avatar, 248 | g.score, 249 | g.id as game_id, 250 | g.created_at, 251 | ROW_NUMBER() OVER (ORDER BY g.score DESC) as rank 252 | FROM ( 253 | SELECT 254 | user_id, 255 | MAX(score) as score, 256 | (ARRAY_AGG(id ORDER BY score DESC))[1] as id, 257 | (ARRAY_AGG(created_at ORDER BY score DESC))[1] as created_at 258 | FROM games 259 | WHERE (game_over = true OR victory = true)` 260 | 261 | var timeFilter string 262 | switch leaderboardType { 263 | case models.LeaderboardDaily: 264 | timeFilter = ` AND created_at >= CURRENT_DATE` 265 | case models.LeaderboardWeekly: 266 | timeFilter = ` AND created_at >= DATE_TRUNC('week', CURRENT_DATE)` 267 | case models.LeaderboardMonthly: 268 | timeFilter = ` AND created_at >= DATE_TRUNC('month', CURRENT_DATE)` 269 | case models.LeaderboardAll: 270 | timeFilter = "" 271 | default: 272 | return nil, fmt.Errorf("invalid leaderboard type") 273 | } 274 | 275 | query = baseQuery + timeFilter + ` 276 | GROUP BY user_id 277 | ) g 278 | JOIN users u ON g.user_id = u.id 279 | ORDER BY g.score DESC LIMIT $1` 280 | args = append(args, limit) 281 | 282 | rows, err := p.db.Query(query, args...) 283 | if err != nil { 284 | return nil, fmt.Errorf("failed to query leaderboard: %w", err) 285 | } 286 | defer rows.Close() 287 | 288 | var entries []models.LeaderboardEntry 289 | for rows.Next() { 290 | var entry models.LeaderboardEntry 291 | err := rows.Scan( 292 | &entry.UserID, &entry.UserName, &entry.UserAvatar, 293 | &entry.Score, &entry.GameID, &entry.CreatedAt, &entry.Rank) 294 | if err != nil { 295 | return nil, fmt.Errorf("failed to scan leaderboard entry: %w", err) 296 | } 297 | entries = append(entries, entry) 298 | } 299 | 300 | if err := rows.Err(); err != nil { 301 | return nil, fmt.Errorf("error iterating leaderboard rows: %w", err) 302 | } 303 | 304 | return entries, nil 305 | } 306 | -------------------------------------------------------------------------------- /backend/internal/websocket/handlers.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "time" 7 | 8 | "game2048/pkg/models" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | // handleMove handles move requests from clients 14 | func (c *Client) handleMove(data interface{}) { 15 | // Parse move request 16 | dataBytes, err := json.Marshal(data) 17 | if err != nil { 18 | c.sendError("Invalid move data") 19 | return 20 | } 21 | 22 | var moveRequest models.MoveRequest 23 | if err := json.Unmarshal(dataBytes, &moveRequest); err != nil { 24 | c.sendError("Invalid move request format") 25 | return 26 | } 27 | 28 | // Validate direction 29 | if moveRequest.Direction != models.DirectionUp && 30 | moveRequest.Direction != models.DirectionDown && 31 | moveRequest.Direction != models.DirectionLeft && 32 | moveRequest.Direction != models.DirectionRight { 33 | c.sendError("Invalid direction") 34 | return 35 | } 36 | 37 | // Get current game state 38 | gameState, err := c.getCurrentGameState() 39 | if err != nil { 40 | c.sendError("Failed to get game state") 41 | return 42 | } 43 | 44 | if gameState == nil { 45 | c.sendError("No active game found. Start a new game first.") 46 | return 47 | } 48 | 49 | // Check if game is already over 50 | if gameState.GameOver || gameState.Victory { 51 | c.sendError("Game is already finished") 52 | return 53 | } 54 | 55 | // Execute move 56 | newBoard, scoreGained, moved := c.hub.gameEngine.Move(gameState.Board, moveRequest.Direction) 57 | if !moved { 58 | c.sendError("Invalid move - no tiles moved") 59 | return 60 | } 61 | 62 | // Update game state 63 | gameState.Board = newBoard 64 | gameState.Score += scoreGained 65 | 66 | // Check for victory 67 | if c.hub.gameEngine.IsVictory(gameState.Board) { 68 | gameState.Victory = true 69 | } 70 | 71 | // Check for game over 72 | if c.hub.gameEngine.IsGameOver(gameState.Board) { 73 | gameState.GameOver = true 74 | } 75 | 76 | // Save updated game state to cache 77 | if c.hub.cache != nil { 78 | // Cache for 1 hour 79 | if err := c.hub.cache.SetGameSession(c.userID, gameState, time.Hour); err != nil { 80 | log.Printf("Failed to cache game session: %v", err) 81 | } 82 | } 83 | 84 | // Only save to database if game is finished (for leaderboard purposes) 85 | if gameState.GameOver || gameState.Victory { 86 | // Try to update first, if it fails (game not in DB), create it 87 | if err := c.hub.db.UpdateGame(gameState); err != nil { 88 | log.Printf("Failed to update game state, trying to create: %v", err) 89 | // Game doesn't exist in database, create it 90 | if err := c.hub.db.CreateGame(gameState); err != nil { 91 | log.Printf("Failed to create game state in database: %v", err) 92 | // Don't return error here, game state is still cached 93 | } else { 94 | log.Printf("Successfully created finished game in database") 95 | } 96 | } else { 97 | log.Printf("Successfully updated finished game in database") 98 | } 99 | } 100 | 101 | // Send response 102 | response := models.GameResponse{ 103 | Board: gameState.Board, 104 | Score: gameState.Score, 105 | GameOver: gameState.GameOver, 106 | Victory: gameState.Victory, 107 | } 108 | 109 | if gameState.Victory { 110 | response.Message = "Congratulations! You merged two 8192 tiles and won!" 111 | } else if gameState.GameOver { 112 | response.Message = "Game Over! No more moves available." 113 | } 114 | 115 | message := models.WebSocketMessage{ 116 | Type: "game_state", 117 | Data: response, 118 | } 119 | 120 | c.sendMessage(message) 121 | 122 | // If game is finished, update leaderboards 123 | if gameState.GameOver || gameState.Victory { 124 | go c.updateLeaderboards(gameState) 125 | } 126 | } 127 | 128 | // handleNewGame handles new game requests 129 | func (c *Client) handleNewGame(data interface{}) { 130 | // Create new game 131 | board := c.hub.gameEngine.NewGame() 132 | gameID := uuid.New() 133 | 134 | gameState := &models.GameState{ 135 | ID: gameID, 136 | UserID: c.userID, 137 | Board: board, 138 | Score: 0, 139 | GameOver: false, 140 | Victory: false, 141 | } 142 | 143 | // Save new game state to cache 144 | if c.hub.cache != nil { 145 | // Cache for 1 hour 146 | if err := c.hub.cache.SetGameSession(c.userID, gameState, time.Hour); err != nil { 147 | log.Printf("Failed to cache new game session: %v", err) 148 | c.sendError("Failed to create new game") 149 | return 150 | } 151 | } else { 152 | // Fallback to database if no cache 153 | if err := c.hub.db.CreateGame(gameState); err != nil { 154 | log.Printf("Failed to create new game: %v", err) 155 | c.sendError("Failed to create new game") 156 | return 157 | } 158 | } 159 | 160 | // Update client's game ID 161 | c.gameID = gameID 162 | 163 | // Send response 164 | response := models.GameResponse{ 165 | Board: gameState.Board, 166 | Score: gameState.Score, 167 | GameOver: gameState.GameOver, 168 | Victory: gameState.Victory, 169 | Message: "New game started!", 170 | } 171 | 172 | message := models.WebSocketMessage{ 173 | Type: "game_state", 174 | Data: response, 175 | } 176 | 177 | c.sendMessage(message) 178 | } 179 | 180 | // handleGetLeaderboard handles leaderboard requests 181 | func (c *Client) handleGetLeaderboard(data interface{}) { 182 | // Parse leaderboard request 183 | dataBytes, err := json.Marshal(data) 184 | if err != nil { 185 | c.sendError("Invalid leaderboard data") 186 | return 187 | } 188 | 189 | var leaderboardRequest models.LeaderboardRequest 190 | if err := json.Unmarshal(dataBytes, &leaderboardRequest); err != nil { 191 | c.sendError("Invalid leaderboard request format") 192 | return 193 | } 194 | 195 | // Validate leaderboard type 196 | if leaderboardRequest.Type != models.LeaderboardDaily && 197 | leaderboardRequest.Type != models.LeaderboardWeekly && 198 | leaderboardRequest.Type != models.LeaderboardMonthly && 199 | leaderboardRequest.Type != models.LeaderboardAll { 200 | c.sendError("Invalid leaderboard type") 201 | return 202 | } 203 | 204 | // Get leaderboard entries 205 | entries, err := c.hub.db.GetLeaderboard(leaderboardRequest.Type, 100) 206 | if err != nil { 207 | log.Printf("Failed to get leaderboard: %v", err) 208 | c.sendError("Failed to get leaderboard") 209 | return 210 | } 211 | 212 | // Send response 213 | response := models.LeaderboardResponse{ 214 | Type: leaderboardRequest.Type, 215 | Rankings: entries, 216 | } 217 | 218 | message := models.WebSocketMessage{ 219 | Type: "leaderboard", 220 | Data: response, 221 | } 222 | 223 | c.sendMessage(message) 224 | } 225 | 226 | // getCurrentGameState gets the current game state for the client 227 | func (c *Client) getCurrentGameState() (*models.GameState, error) { 228 | // Try to get from Redis cache first 229 | if c.hub.cache != nil { 230 | gameState, err := c.hub.cache.GetGameSession(c.userID) 231 | if err == nil && gameState != nil { 232 | c.gameID = gameState.ID 233 | return gameState, nil 234 | } 235 | } 236 | 237 | // Cache miss or no cache, try database as fallback 238 | if c.gameID == uuid.Nil { 239 | // Try to get user's active game 240 | gameState, err := c.hub.db.GetUserActiveGame(c.userID) 241 | if err != nil { 242 | return nil, err 243 | } 244 | if gameState != nil { 245 | c.gameID = gameState.ID 246 | // Cache the game state 247 | if c.hub.cache != nil { 248 | if err := c.hub.cache.SetGameSession(c.userID, gameState, time.Hour); err != nil { 249 | log.Printf("Failed to cache game session: %v", err) 250 | } 251 | } 252 | } 253 | return gameState, nil 254 | } 255 | 256 | // Get game by ID from database 257 | gameState, err := c.hub.db.GetGame(c.gameID.String(), c.userID) 258 | if err == nil && gameState != nil && c.hub.cache != nil { 259 | // Cache the game state 260 | if err := c.hub.cache.SetGameSession(c.userID, gameState, time.Hour); err != nil { 261 | log.Printf("Failed to cache game session: %v", err) 262 | } 263 | } 264 | return gameState, err 265 | } 266 | 267 | // updateLeaderboards updates the leaderboard cache when a game finishes 268 | func (c *Client) updateLeaderboards(gameState *models.GameState) { 269 | log.Printf("Game finished for user %s with score %d", c.userID, gameState.Score) 270 | 271 | // Invalidate leaderboard caches so they will be refreshed on next request 272 | if c.hub.cache != nil { 273 | leaderboardTypes := []models.LeaderboardType{ 274 | models.LeaderboardDaily, 275 | models.LeaderboardWeekly, 276 | models.LeaderboardMonthly, 277 | models.LeaderboardAll, 278 | } 279 | 280 | for _, lbType := range leaderboardTypes { 281 | if err := c.hub.cache.InvalidateLeaderboard(lbType); err != nil { 282 | log.Printf("Failed to invalidate %s leaderboard cache: %v", lbType, err) 283 | } else { 284 | log.Printf("Invalidated %s leaderboard cache", lbType) 285 | } 286 | } 287 | } 288 | 289 | // Optionally broadcast leaderboard updates to connected clients 290 | // This could be expensive with many concurrent games, so we'll skip it for now 291 | // go c.hub.broadcastLeaderboardUpdate(models.LeaderboardAll) 292 | } 293 | 294 | // broadcastLeaderboardUpdate broadcasts leaderboard updates to all connected clients 295 | func (h *Hub) broadcastLeaderboardUpdate(leaderboardType models.LeaderboardType) { 296 | entries, err := h.db.GetLeaderboard(leaderboardType, 100) 297 | if err != nil { 298 | log.Printf("Failed to get leaderboard for broadcast: %v", err) 299 | return 300 | } 301 | 302 | response := models.LeaderboardResponse{ 303 | Type: leaderboardType, 304 | Rankings: entries, 305 | } 306 | 307 | message := models.WebSocketMessage{ 308 | Type: "leaderboard_update", 309 | Data: response, 310 | } 311 | 312 | data, err := json.Marshal(message) 313 | if err != nil { 314 | log.Printf("Failed to marshal leaderboard update: %v", err) 315 | return 316 | } 317 | 318 | // Broadcast to all clients 319 | h.broadcast <- data 320 | } 321 | -------------------------------------------------------------------------------- /backend/cmd/server/static/js/main.js: -------------------------------------------------------------------------------- 1 | // Main application initialization and coordination 2 | (function() { 3 | 'use strict'; 4 | 5 | // Application state 6 | window.app = { 7 | initialized: false, 8 | components: {} 9 | }; 10 | 11 | // Initialize the application 12 | function initializeApp() { 13 | if (window.app.initialized) return; 14 | 15 | console.log('Initializing 2048 Game Application...'); 16 | 17 | // Check if we're on the game page 18 | if (document.getElementById('game-board')) { 19 | initializeGamePage(); 20 | } else { 21 | initializeOtherPages(); 22 | } 23 | 24 | // Set up global error handling 25 | setupErrorHandling(); 26 | 27 | // Set up performance monitoring 28 | setupPerformanceMonitoring(); 29 | 30 | window.app.initialized = true; 31 | console.log('Application initialized successfully'); 32 | } 33 | 34 | function initializeGamePage() { 35 | console.log('Initializing game page...'); 36 | 37 | // Wait for all components to be loaded 38 | const checkComponents = setInterval(() => { 39 | if (window.gameWS && window.game && window.leaderboard && window.auth) { 40 | clearInterval(checkComponents); 41 | 42 | // Store component references 43 | window.app.components = { 44 | websocket: window.gameWS, 45 | game: window.game, 46 | leaderboard: window.leaderboard, 47 | auth: window.auth 48 | }; 49 | 50 | // Set up component interactions 51 | setupComponentInteractions(); 52 | 53 | console.log('Game page initialized'); 54 | } 55 | }, 100); 56 | 57 | // Timeout after 10 seconds 58 | setTimeout(() => { 59 | clearInterval(checkComponents); 60 | if (!window.app.components.websocket) { 61 | console.error('Failed to initialize components within timeout'); 62 | showInitializationError(); 63 | } 64 | }, 10000); 65 | } 66 | 67 | function initializeOtherPages() { 68 | console.log('Initializing non-game page...'); 69 | 70 | // Set up any common functionality for non-game pages 71 | setupCommonFeatures(); 72 | } 73 | 74 | function setupComponentInteractions() { 75 | // Set up cross-component communication 76 | 77 | // Game state updates should refresh leaderboard 78 | const originalUpdateGameState = window.game.updateGameState; 79 | window.game.updateGameState = function(gameState) { 80 | originalUpdateGameState.call(this, gameState); 81 | 82 | // If game finished, refresh current leaderboard 83 | if (gameState.game_over || gameState.victory) { 84 | setTimeout(() => { 85 | if (window.leaderboard) { 86 | window.leaderboard.loadLeaderboard(window.leaderboard.currentType); 87 | } 88 | }, 1000); 89 | } 90 | }; 91 | 92 | // WebSocket reconnection should reload leaderboard 93 | const originalConnect = window.gameWS.connect; 94 | window.gameWS.connect = function() { 95 | originalConnect.call(this); 96 | 97 | // Reload leaderboard when reconnected 98 | this.ws.addEventListener('open', () => { 99 | if (window.leaderboard) { 100 | setTimeout(() => { 101 | window.leaderboard.loadLeaderboard(window.leaderboard.currentType); 102 | }, 500); 103 | } 104 | }); 105 | }; 106 | } 107 | 108 | function setupErrorHandling() { 109 | // Global error handler 110 | window.addEventListener('error', (event) => { 111 | console.error('Global error:', event.error); 112 | 113 | // Don't show error for script loading failures (common in development) 114 | if (event.filename && event.filename.includes('.js')) { 115 | return; 116 | } 117 | 118 | showGlobalError('An unexpected error occurred. Please refresh the page.'); 119 | }); 120 | 121 | // Unhandled promise rejection handler 122 | window.addEventListener('unhandledrejection', (event) => { 123 | console.error('Unhandled promise rejection:', event.reason); 124 | showGlobalError('A network error occurred. Please check your connection.'); 125 | }); 126 | } 127 | 128 | function setupPerformanceMonitoring() { 129 | // Monitor page load performance 130 | window.addEventListener('load', () => { 131 | if ('performance' in window) { 132 | const perfData = performance.getEntriesByType('navigation')[0]; 133 | if (perfData) { 134 | console.log('Page load time:', perfData.loadEventEnd - perfData.loadEventStart, 'ms'); 135 | console.log('DOM content loaded:', perfData.domContentLoadedEventEnd - perfData.domContentLoadedEventStart, 'ms'); 136 | } 137 | } 138 | }); 139 | 140 | // Monitor memory usage (if available) 141 | if ('memory' in performance) { 142 | setInterval(() => { 143 | const memory = performance.memory; 144 | if (memory.usedJSHeapSize > memory.jsHeapSizeLimit * 0.9) { 145 | console.warn('High memory usage detected'); 146 | } 147 | }, 30000); // Check every 30 seconds 148 | } 149 | } 150 | 151 | function setupCommonFeatures() { 152 | // Set up common features for all pages 153 | 154 | // Smooth scrolling for anchor links 155 | document.querySelectorAll('a[href^="#"]').forEach(anchor => { 156 | anchor.addEventListener('click', function (e) { 157 | e.preventDefault(); 158 | const target = document.querySelector(this.getAttribute('href')); 159 | if (target) { 160 | target.scrollIntoView({ 161 | behavior: 'smooth' 162 | }); 163 | } 164 | }); 165 | }); 166 | 167 | // Add loading states to buttons 168 | document.querySelectorAll('button[type="submit"]').forEach(button => { 169 | button.addEventListener('click', function() { 170 | if (!this.disabled) { 171 | this.classList.add('loading'); 172 | this.disabled = true; 173 | 174 | // Re-enable after 5 seconds as fallback 175 | setTimeout(() => { 176 | this.classList.remove('loading'); 177 | this.disabled = false; 178 | }, 5000); 179 | } 180 | }); 181 | }); 182 | } 183 | 184 | function showInitializationError() { 185 | const errorDiv = document.createElement('div'); 186 | errorDiv.style.cssText = ` 187 | position: fixed; 188 | top: 0; 189 | left: 0; 190 | right: 0; 191 | bottom: 0; 192 | background: rgba(0, 0, 0, 0.8); 193 | color: white; 194 | display: flex; 195 | align-items: center; 196 | justify-content: center; 197 | z-index: 10000; 198 | font-family: inherit; 199 | `; 200 | 201 | errorDiv.innerHTML = ` 202 |
203 |

Initialization Failed

204 |

The game failed to load properly. Please refresh the page to try again.

205 | 208 |
209 | `; 210 | 211 | document.body.appendChild(errorDiv); 212 | } 213 | 214 | function showGlobalError(message) { 215 | // Don't show multiple error messages 216 | if (document.getElementById('global-error')) return; 217 | 218 | const errorDiv = document.createElement('div'); 219 | errorDiv.id = 'global-error'; 220 | errorDiv.style.cssText = ` 221 | position: fixed; 222 | top: 20px; 223 | left: 50%; 224 | transform: translateX(-50%); 225 | background: #f44336; 226 | color: white; 227 | padding: 15px 20px; 228 | border-radius: 8px; 229 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 230 | z-index: 10000; 231 | max-width: 400px; 232 | font-size: 14px; 233 | line-height: 1.4; 234 | text-align: center; 235 | `; 236 | 237 | errorDiv.textContent = message; 238 | document.body.appendChild(errorDiv); 239 | 240 | // Auto-hide after 10 seconds 241 | setTimeout(() => { 242 | if (errorDiv.parentNode) { 243 | errorDiv.parentNode.removeChild(errorDiv); 244 | } 245 | }, 10000); 246 | } 247 | 248 | // Initialize when DOM is ready 249 | if (document.readyState === 'loading') { 250 | document.addEventListener('DOMContentLoaded', initializeApp); 251 | } else { 252 | initializeApp(); 253 | } 254 | 255 | // Export utilities for debugging 256 | window.app.utils = { 257 | showError: showGlobalError, 258 | reinitialize: initializeApp 259 | }; 260 | 261 | })(); 262 | --------------------------------------------------------------------------------