├── 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 |
119 |
120 |
121 |
If this problem persists, please try:
122 |
123 | - Refreshing the page
124 | - Clearing your browser cache
125 | - Checking your internet connection
126 | - Trying again in a few minutes
127 |
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 |

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 ? `

` : ''}
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 |    
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 |
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 |
16 |
17 |
2048
18 |
Merge two 8192 tiles to win!
19 |
20 |
21 |
22 |
Welcome to 2048
23 |
Sign in to save your progress and compete on the leaderboards
24 |
25 |
40 |
41 |
46 |
47 |
48 |
49 |
🏆 Leaderboards
50 |
Compete with players worldwide
51 |
52 |
53 |
💾 Save Progress
54 |
Continue your games across devices
55 |
56 |
57 |
📱 Mobile Friendly
58 |
Play anywhere, anytime
59 |
60 |
61 |
62 |
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 |
--------------------------------------------------------------------------------