├── Dockerfile ├── go.mod ├── go.sum └── main.go /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for Go backend 2 | 3 | # Use an official Go runtime as a parent image 4 | FROM golang:1.24-alpine AS builder 5 | 6 | # Set the working directory in the container 7 | WORKDIR /app 8 | 9 | # Copy source code 10 | COPY . . 11 | 12 | # Download dependencies 13 | RUN go mod download 14 | 15 | # Build the Go application with optimizations for production 16 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . 17 | 18 | # Start from scratch for minimal image size 19 | FROM scratch 20 | 21 | # Set working directory 22 | WORKDIR /app 23 | 24 | # Copy SSL certificates for HTTPS requests 25 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 26 | 27 | # Copy the binary from builder stage 28 | COPY --from=builder /app/main . 29 | 30 | # Expose port 8080 to the outside world 31 | EXPOSE 8080 32 | 33 | # Command to run the executable 34 | CMD ["/app/main"] 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stonify5/backend 2 | 3 | go 1.19 4 | 5 | require github.com/gorilla/websocket v1.5.3 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 2 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net" 7 | "net/http" 8 | "strconv" 9 | "sync" 10 | "time" 11 | "unicode/utf8" 12 | 13 | "github.com/gorilla/websocket" 14 | ) 15 | 16 | // Constants 17 | const serverAddress = "0.0.0.0:8080" 18 | 19 | const ( 20 | BoardSize = 15 21 | TotalCells = BoardSize * BoardSize 22 | MaxNicknameLength = 10 23 | TimeoutDuration = 60 * time.Second 24 | WinningCount = 5 // For Omok, 5 stones in a row wins. 25 | ) 26 | 27 | const ( 28 | black uint8 = 1 29 | white uint8 = 2 30 | emptied uint8 = 0 31 | ) 32 | 33 | // Game status codes sent to clients 34 | const ( 35 | StatusWin = 0 // You won 36 | StatusLoss = 1 // You lost 37 | StatusUser2Timeout = 2 // Opponent timed out 38 | StatusUser1Timeout = 3 // Opponent timed out (from user2's perspective) 39 | StatusErrorReading = 4 // Error reading opponent's move 40 | ) 41 | 42 | const ( 43 | WebSocketPingType = "ping" 44 | WebSocketPongType = "pong" 45 | ) 46 | 47 | // Types 48 | type OmokRoom struct { 49 | board_15x15 [TotalCells]uint8 50 | user1 user 51 | user2 user 52 | spectators []*websocket.Conn 53 | spectatorsMux sync.Mutex // Protects 'spectators' list for this room 54 | } 55 | 56 | type user struct { 57 | ws *websocket.Conn 58 | check bool // True if this user slot is active 59 | nickname string 60 | } 61 | 62 | // Message structure for WebSocket communication 63 | type Message struct { 64 | Data interface{} `json:"data,omitempty"` 65 | YourColor interface{} `json:"YourColor,omitempty"` 66 | Message interface{} `json:"message,omitempty"` // Can be game status, error, or text 67 | NumUsers interface{} `json:"numUsers,omitempty"` 68 | Nickname interface{} `json:"nickname,omitempty"` // Opponent's nickname 69 | } 70 | 71 | type SpectatorMessage struct { 72 | Board interface{} `json:"board,omitempty"` 73 | Data interface{} `json:"data,omitempty"` // Move data 74 | Color interface{} `json:"color,omitempty"` // Color of the stone placed 75 | User1 interface{} `json:"user1,omitempty"` // Nickname of user1 (black) 76 | User2 interface{} `json:"user2,omitempty"` // Nickname of user2 (white) 77 | } 78 | 79 | // Global variables 80 | var ( 81 | upgrader = websocket.Upgrader{ 82 | // Allow cross-origin requests for development and deployment 83 | CheckOrigin: func(r *http.Request) bool { return true }, 84 | } 85 | rooms []*OmokRoom 86 | sockets []*websocket.Conn // List of all active WebSocket connections (players + spectators) 87 | globalMutex sync.Mutex // Protects 'rooms', 'sockets', and 'connectionsCount' 88 | connectionsCount = 0 89 | ) 90 | 91 | // Handles matching users to rooms or creating new rooms. 92 | func RoomMatching(ws *websocket.Conn) { 93 | log.Printf("Connection attempt from %s. Waiting for nickname.", ws.RemoteAddr()) 94 | _, nicknameBytes, err := ws.ReadMessage() 95 | nickname := string(nicknameBytes) 96 | nicknameLength := utf8.RuneCountInString(nickname) 97 | 98 | if err != nil { 99 | log.Printf("Error reading nickname from %s: %v", ws.RemoteAddr(), err) 100 | handleConnectionFailure(ws) 101 | return 102 | } 103 | if nicknameLength == 0 { 104 | log.Printf("Empty nickname received from %s", ws.RemoteAddr()) 105 | handleConnectionFailure(ws) 106 | return 107 | } 108 | if nicknameLength > MaxNicknameLength { 109 | log.Printf("Nickname too long from %s: %d characters (max %d)", ws.RemoteAddr(), nicknameLength, MaxNicknameLength) 110 | handleConnectionFailure(ws) 111 | return 112 | } 113 | 114 | log.Printf("Nickname received from %s: %s", ws.RemoteAddr(), nickname) 115 | 116 | globalMutex.Lock() 117 | defer globalMutex.Unlock() 118 | 119 | // Clean up disconnected waiting players before matching 120 | cleanupDisconnectedWaitingPlayers() 121 | 122 | for _, room := range rooms { 123 | if room.user1.check && !room.user2.check { // Found a room with one player waiting 124 | // Quick check - if WebSocket is nil, skip this room and let cleanup handle it 125 | if room.user1.ws != nil { 126 | // Just assign user2 to this room - don't ping during matching 127 | room.user2.check = true 128 | room.user2.ws = ws 129 | room.user2.nickname = nickname 130 | log.Printf("User %s (%s) joined User %s (%s) in a room.", nickname, ws.RemoteAddr(), room.user1.nickname, room.user1.ws.RemoteAddr()) 131 | 132 | // Increment connection count for user2 133 | connectionsCount++ 134 | currentConnections := connectionsCount 135 | log.Printf("Connection count incremented for player %s (user2), current total: %d", nickname, currentConnections) 136 | BroadcastConnectionsCountUnsafe(currentConnections) 137 | 138 | // Unlock globalMutex before starting MessageHandler as it's a long-running blocking call 139 | // and MessageHandler itself will manage room state. 140 | go room.MessageHandler() 141 | return // Successfully matched 142 | } else { 143 | // Waiting player WebSocket is nil, this room will be cleaned up 144 | log.Printf("Skipping room with nil WebSocket for waiting player: %s", room.user1.nickname) 145 | } 146 | } 147 | } 148 | 149 | // No suitable room found, create a new one for this player as user1 150 | newRoom := &OmokRoom{} 151 | newRoom.user1.check = true 152 | newRoom.user1.ws = ws 153 | newRoom.user1.nickname = nickname 154 | rooms = append(rooms, newRoom) 155 | log.Printf("User %s (%s) created a new room.", nickname, ws.RemoteAddr()) 156 | // User1 will wait in this room; MessageHandler is not started until user2 joins. 157 | 158 | // Increment connection count only after successful room assignment 159 | connectionsCount++ 160 | currentConnections := connectionsCount 161 | log.Printf("Connection count incremented for player %s, current total: %d", nickname, currentConnections) 162 | BroadcastConnectionsCountUnsafe(currentConnections) 163 | } 164 | 165 | // Main game loop for a room. 166 | func (room *OmokRoom) MessageHandler() { 167 | log.Printf("Game starting between %s (black) and %s (white).", room.user1.nickname, room.user2.nickname) 168 | 169 | // Notify players of game start and their colors/opponent's nickname 170 | err1 := room.user1.ws.WriteJSON(Message{YourColor: "black", Nickname: room.user2.nickname}) 171 | err2 := room.user2.ws.WriteJSON(Message{YourColor: "white", Nickname: room.user1.nickname}) 172 | 173 | if err1 != nil || err2 != nil { 174 | log.Printf("Error sending initial game messages to %s or %s. Resetting room.", room.user1.nickname, room.user2.nickname) 175 | room.reset() 176 | return 177 | } 178 | 179 | var currentMoveIndex int 180 | var timeout bool 181 | var readErr bool 182 | 183 | for { 184 | // Black's turn (user1) 185 | currentMoveIndex, timeout, readErr = reading(room.user1.ws) 186 | if timeout { 187 | log.Printf("User %s (black) timed out. %s (white) wins.", room.user1.nickname, room.user2.nickname) 188 | if room.user2.ws != nil { 189 | room.user2.ws.WriteJSON(Message{Message: StatusUser1Timeout}) 190 | } 191 | if room.user1.ws != nil { 192 | room.user1.ws.WriteJSON(Message{Message: StatusLoss}) 193 | } 194 | room.reset() 195 | return 196 | } 197 | if readErr { 198 | log.Printf("Error reading from %s (black). %s (white) wins by default.", room.user1.nickname, room.user2.nickname) 199 | if room.user2.ws != nil { 200 | room.user2.ws.WriteJSON(Message{Message: StatusErrorReading}) 201 | } 202 | room.reset() 203 | return 204 | } 205 | if room.isValidMove(currentMoveIndex) { 206 | room.board_15x15[currentMoveIndex] = black 207 | if room.user2.ws != nil { 208 | if err := room.user2.ws.WriteJSON(Message{Data: currentMoveIndex}); err != nil { 209 | log.Printf("Error sending move to %s (white). Resetting room.", room.user2.nickname) 210 | room.reset() 211 | return 212 | } 213 | } 214 | room.broadcastMoveToSpectators(currentMoveIndex, black) 215 | if room.VictoryConfirm(currentMoveIndex) { 216 | log.Printf("User %s (black) wins!", room.user1.nickname) 217 | if room.user1.ws != nil { 218 | room.user1.ws.WriteJSON(Message{Message: StatusWin}) 219 | } 220 | if room.user2.ws != nil { 221 | room.user2.ws.WriteJSON(Message{Message: StatusLoss}) 222 | } 223 | room.reset() 224 | return 225 | } 226 | } else { 227 | log.Printf("Invalid move from %s (black). Game reset.", room.user1.nickname) 228 | room.reset() 229 | return 230 | } 231 | 232 | // White's turn (user2) 233 | currentMoveIndex, timeout, readErr = reading(room.user2.ws) 234 | if timeout { 235 | log.Printf("User %s (white) timed out. %s (black) wins.", room.user2.nickname, room.user1.nickname) 236 | if room.user1.ws != nil { 237 | room.user1.ws.WriteJSON(Message{Message: StatusUser2Timeout}) 238 | } 239 | if room.user2.ws != nil { 240 | room.user2.ws.WriteJSON(Message{Message: StatusLoss}) 241 | } 242 | room.reset() 243 | return 244 | } 245 | if readErr { 246 | log.Printf("Error reading from %s (white). %s (black) wins by default.", room.user2.nickname, room.user1.nickname) 247 | if room.user1.ws != nil { 248 | room.user1.ws.WriteJSON(Message{Message: StatusErrorReading}) 249 | } 250 | room.reset() 251 | return 252 | } 253 | if room.isValidMove(currentMoveIndex) { 254 | room.board_15x15[currentMoveIndex] = white 255 | if room.user1.ws != nil { 256 | if err := room.user1.ws.WriteJSON(Message{Data: currentMoveIndex}); err != nil { 257 | log.Printf("Error sending move to %s (black). Resetting room.", room.user1.nickname) 258 | room.reset() 259 | return 260 | } 261 | } 262 | room.broadcastMoveToSpectators(currentMoveIndex, white) 263 | if room.VictoryConfirm(currentMoveIndex) { 264 | log.Printf("User %s (white) wins!", room.user2.nickname) 265 | if room.user2.ws != nil { 266 | room.user2.ws.WriteJSON(Message{Message: StatusWin}) 267 | } 268 | if room.user1.ws != nil { 269 | room.user1.ws.WriteJSON(Message{Message: StatusLoss}) 270 | } 271 | room.reset() 272 | return 273 | } 274 | } else { 275 | log.Printf("Invalid move from %s (white). Game reset.", room.user2.nickname) 276 | room.reset() 277 | return 278 | } 279 | } 280 | } 281 | 282 | func (room *OmokRoom) isValidMove(index int) bool { 283 | return index >= 0 && index < TotalCells && room.board_15x15[index] == emptied 284 | } 285 | 286 | func (room *OmokRoom) broadcastMoveToSpectators(moveIndex int, color uint8) { 287 | room.spectatorsMux.Lock() 288 | defer room.spectatorsMux.Unlock() 289 | 290 | message := SpectatorMessage{Data: moveIndex, Color: color} 291 | for _, spectatorWs := range room.spectators { 292 | if spectatorWs != nil { 293 | if err := spectatorWs.WriteJSON(message); err != nil { 294 | log.Printf("Error broadcasting move to spectator %s: %v. Will be cleaned up.", spectatorWs.RemoteAddr(), err) 295 | // Mark spectator for removal or handle error; removal is usually done by spectator's own handler. 296 | } 297 | } 298 | } 299 | } 300 | 301 | // VictoryConfirm checks if the last move at 'index' resulted in a win. 302 | // Assumes 'index' is a valid move and the stone is already placed on room.board_15x15. 303 | func (room *OmokRoom) VictoryConfirm(index int) bool { 304 | stoneColor := room.board_15x15[index] 305 | if stoneColor == emptied { 306 | return false // Should not happen if called after a move 307 | } 308 | 309 | // Directions to check: horizontal, vertical, diagonal (down-right), diagonal (down-left) 310 | // dx, dy pairs for these directions 311 | checkDirections := [][2]int{ 312 | {0, 1}, // Horizontal (col changes) 313 | {1, 0}, // Vertical (row changes) 314 | {1, 1}, // Diagonal \ (row and col change same way) 315 | {1, -1}, // Diagonal / (row and col change opposite ways) 316 | } 317 | 318 | y := index / BoardSize 319 | x := index % BoardSize 320 | 321 | for _, dir := range checkDirections { 322 | dy, dx := dir[0], dir[1] 323 | count := 1 // Count the stone just placed 324 | 325 | // Check in the positive direction (e.g., right, down, down-right, down-left) 326 | for i := 1; i < WinningCount; i++ { 327 | curX, curY := x+dx*i, y+dy*i 328 | if curX >= 0 && curX < BoardSize && curY >= 0 && curY < BoardSize && 329 | room.board_15x15[curY*BoardSize+curX] == stoneColor { 330 | count++ 331 | } else { 332 | break 333 | } 334 | } 335 | 336 | // Check in the negative direction (e.g., left, up, up-left, up-right) 337 | for i := 1; i < WinningCount; i++ { 338 | curX, curY := x-dx*i, y-dy*i 339 | if curX >= 0 && curX < BoardSize && curY >= 0 && curY < BoardSize && 340 | room.board_15x15[curY*BoardSize+curX] == stoneColor { 341 | count++ 342 | } else { 343 | break 344 | } 345 | } 346 | // "장목 불가" (no overlines win) rule means exactly WinningCount stones. 347 | if count == WinningCount { 348 | return true 349 | } 350 | } 351 | return false 352 | } 353 | 354 | func (room *OmokRoom) SendVictoryMessage(winnerColor uint8) { 355 | // This function seems to be duplicated by logic within MessageHandler. 356 | // Keeping it for now if it's intended for other uses, but MessageHandler directly sends win/loss. 357 | // If winnerColor is black (user1) 358 | if winnerColor == black { 359 | if room.user1.ws != nil { 360 | room.user1.ws.WriteJSON(Message{Message: StatusWin}) 361 | } 362 | if room.user2.ws != nil { 363 | room.user2.ws.WriteJSON(Message{Message: StatusLoss}) 364 | } 365 | } else { // winnerColor is white (user2) 366 | if room.user2.ws != nil { 367 | room.user2.ws.WriteJSON(Message{Message: StatusWin}) 368 | } 369 | if room.user1.ws != nil { 370 | room.user1.ws.WriteJSON(Message{Message: StatusLoss}) 371 | } 372 | } 373 | } 374 | 375 | // Reads a message from WebSocket with timeout. 376 | // Returns (message_as_int, timeout_occurred, other_error_occurred) 377 | func reading(ws *websocket.Conn) (int, bool, bool) { 378 | if ws == nil { 379 | return 0, false, true // Error if ws is nil 380 | } 381 | 382 | for { 383 | ws.SetReadDeadline(time.Now().Add(TimeoutDuration)) 384 | _, msgBytes, err := ws.ReadMessage() 385 | ws.SetReadDeadline(time.Time{}) // Clear the deadline 386 | 387 | if err != nil { 388 | if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 389 | return 0, true, false // Timeout 390 | } 391 | log.Printf("Error reading from WebSocket %s: %v", ws.RemoteAddr(), err) 392 | return 0, false, true // Other error 393 | } 394 | 395 | msgStr := string(msgBytes) 396 | 397 | // Handle ping/pong messages - don't treat them as game moves 398 | if msgStr == WebSocketPongType { 399 | // Client responded to our ping, continue reading for actual game move 400 | continue 401 | } 402 | 403 | // Check if this is a JSON ping message 404 | var jsonMsg map[string]interface{} 405 | if json.Unmarshal(msgBytes, &jsonMsg) == nil { 406 | if msgType, exists := jsonMsg["type"]; exists && msgType == WebSocketPingType { 407 | // Send pong response 408 | ws.WriteJSON(map[string]interface{}{"type": WebSocketPongType}) 409 | continue 410 | } 411 | } 412 | 413 | // Try to parse as game move 414 | moveIndex, convErr := strconv.Atoi(msgStr) 415 | if convErr != nil { 416 | log.Printf("Error converting message to int from %s ('%s'): %v", ws.RemoteAddr(), msgStr, convErr) 417 | return 0, false, true // Conversion error 418 | } 419 | return moveIndex, false, false 420 | } 421 | } 422 | 423 | // Prepares a room for reset by nullifying its user WebSockets. 424 | // Actual removal from global lists and count decrements should be handled carefully with locks. 425 | func (room *OmokRoom) prepareForReset() { 426 | if room.user1.ws != nil { 427 | room.user1.ws.Close() // Attempt to close, ignore error as we are resetting anyway 428 | // Decrementing connectionsCount is done by the caller or a central cleanup 429 | } 430 | if room.user2.ws != nil { 431 | room.user2.ws.Close() 432 | } 433 | room.user1.ws = nil 434 | room.user2.ws = nil 435 | room.user1.check = false 436 | room.user2.check = false 437 | 438 | // Clear spectators for this room 439 | room.spectatorsMux.Lock() 440 | for _, specWs := range room.spectators { 441 | if specWs != nil { 442 | specWs.Close() // Close spectator connections for this room 443 | // Spectator's own handler (handleSocketError) should manage global 'sockets' and 'connectionsCount' 444 | } 445 | } 446 | room.spectators = nil // Clear the list 447 | room.spectatorsMux.Unlock() 448 | } 449 | 450 | // Resets a game room, closes connections, and cleans up. 451 | func (room *OmokRoom) reset() { 452 | log.Printf("Resetting room (User1: %s, User2: %s)...", room.user1.nickname, room.user2.nickname) 453 | 454 | // Store WebSocket references and check flags before nullifying them 455 | user1WS := room.user1.ws 456 | user2WS := room.user2.ws 457 | user1Active := room.user1.check 458 | user2Active := room.user2.check 459 | 460 | room.prepareForReset() // Close WebSockets and clear user checks 461 | 462 | globalMutex.Lock() 463 | defer globalMutex.Unlock() 464 | 465 | // Properly handle connection count decrements for players 466 | // Only decrement if the user was actually active and their WebSocket is in our list 467 | connectionsDecremented := 0 468 | 469 | if user1Active && user1WS != nil { 470 | // Check if this WebSocket is still in our global list before decrementing 471 | wasInList := false 472 | for _, socket := range sockets { 473 | if socket == user1WS { 474 | wasInList = true 475 | break 476 | } 477 | } 478 | if wasInList { 479 | removeWebSocketFromSocketsUnsafe(user1WS) 480 | connectionsCount-- 481 | connectionsDecremented++ 482 | log.Printf("Decremented connection count for user1 %s", room.user1.nickname) 483 | } else { 484 | log.Printf("User1 %s WebSocket already removed from global list", room.user1.nickname) 485 | } 486 | } 487 | 488 | if user2Active && user2WS != nil { 489 | // Check if this WebSocket is still in our global list before decrementing 490 | wasInList := false 491 | for _, socket := range sockets { 492 | if socket == user2WS { 493 | wasInList = true 494 | break 495 | } 496 | } 497 | if wasInList { 498 | removeWebSocketFromSocketsUnsafe(user2WS) 499 | connectionsCount-- 500 | connectionsDecremented++ 501 | log.Printf("Decremented connection count for user2 %s", room.user2.nickname) 502 | } else { 503 | log.Printf("User2 %s WebSocket already removed from global list", room.user2.nickname) 504 | } 505 | } 506 | 507 | removeRoomFromRoomsUnsafe(room) 508 | currentConnections := connectionsCount 509 | 510 | if connectionsDecremented > 0 { 511 | log.Printf("Room reset: decremented %d connections, current total: %d", connectionsDecremented, currentConnections) 512 | BroadcastConnectionsCountUnsafe(currentConnections) 513 | } else { 514 | log.Printf("Room reset: no connections decremented (already cleaned up), current total: %d", currentConnections) 515 | } 516 | } 517 | 518 | // General handler for a failed/closed WebSocket connection. 519 | // This should only be called for spectators or in error cases, not for normal game room cleanup. 520 | func handleConnectionFailure(ws *websocket.Conn) { 521 | if ws == nil { 522 | return 523 | } 524 | log.Printf("Handling connection failure/closure for %s", ws.RemoteAddr()) 525 | ws.Close() 526 | 527 | globalMutex.Lock() 528 | defer globalMutex.Unlock() 529 | 530 | // Check if WebSocket is still in the sockets list before removing and decrementing 531 | wasInList := false 532 | for _, socket := range sockets { 533 | if socket == ws { 534 | wasInList = true 535 | break 536 | } 537 | } 538 | 539 | if wasInList { 540 | removeWebSocketFromSocketsUnsafe(ws) 541 | connectionsCount-- 542 | currentConnections := connectionsCount 543 | log.Printf("Connection failure cleanup: decremented count for %s, current total: %d", ws.RemoteAddr(), currentConnections) 544 | BroadcastConnectionsCountUnsafe(currentConnections) 545 | } else { 546 | log.Printf("Connection failure: WebSocket %s already removed from connections list", ws.RemoteAddr()) 547 | } 548 | } 549 | 550 | // Removes a room from the global 'rooms' list. Assumes globalMutex is held by caller. 551 | func removeRoomFromRoomsUnsafe(roomToRemove *OmokRoom) { 552 | newRooms := []*OmokRoom{} 553 | for _, r := range rooms { 554 | if r != roomToRemove { 555 | newRooms = append(newRooms, r) 556 | } 557 | } 558 | rooms = newRooms 559 | log.Printf("Room removed. Current rooms: %d", len(rooms)) 560 | } 561 | 562 | // Broadcasts the current number of connections to all sockets. Assumes globalMutex is held by caller. 563 | func BroadcastConnectionsCountUnsafe(count int) { 564 | log.Printf("Broadcasting connection count: %d to %d sockets", count, len(sockets)) 565 | message := Message{NumUsers: count} 566 | for _, s := range sockets { 567 | if s != nil { 568 | if err := s.WriteJSON(message); err != nil { 569 | // Log error, but don't let one bad socket stop broadcast to others. 570 | // Bad sockets will be cleaned up by their own handlers. 571 | log.Printf("Error broadcasting connection count to %s: %v", s.RemoteAddr(), err) 572 | } 573 | } 574 | } 575 | } 576 | 577 | // Wrapper for broadcasting connection count that handles locking. 578 | func BroadcastConnectionsCount(count int) { 579 | globalMutex.Lock() 580 | defer globalMutex.Unlock() 581 | BroadcastConnectionsCountUnsafe(count) 582 | } 583 | 584 | // Upgrades HTTP connection to WebSocket and adds to global list. 585 | func upgradeWebSocketConnection(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) { 586 | ws, err := upgrader.Upgrade(w, r, nil) 587 | if err != nil { 588 | log.Printf("WebSocket upgrade failed for %s: %v", r.RemoteAddr, err) // Corrected: r.RemoteAddr without parentheses 589 | return nil, err 590 | } 591 | log.Printf("WebSocket connection established from %s", ws.RemoteAddr()) 592 | 593 | globalMutex.Lock() 594 | sockets = append(sockets, ws) 595 | globalMutex.Unlock() 596 | // Note: connectionsCount is incremented by player/spectator specific logic, not here. 597 | return ws, nil 598 | } 599 | 600 | // HTTP handler for game connections. 601 | func SocketHandler(w http.ResponseWriter, r *http.Request) { 602 | log.Printf("Game connection request from %s", r.RemoteAddr) 603 | ws, err := upgradeWebSocketConnection(w, r) 604 | if err != nil { 605 | log.Printf("Failed to upgrade game connection from %s: %v", r.RemoteAddr, err) 606 | return // Error already logged by upgradeWebSocketConnection 607 | } 608 | 609 | log.Printf("Starting room matching for %s", ws.RemoteAddr()) 610 | RoomMatching(ws) 611 | } 612 | 613 | // HTTP handler for spectator connections. 614 | func SpectatorHandler(w http.ResponseWriter, r *http.Request) { 615 | ws, err := upgradeWebSocketConnection(w, r) 616 | if err != nil { 617 | return 618 | } 619 | 620 | globalMutex.Lock() 621 | connectionsCount++ 622 | currentConnections := connectionsCount 623 | globalMutex.Unlock() 624 | BroadcastConnectionsCount(currentConnections) 625 | 626 | go func(spectatorWs *websocket.Conn) { 627 | log.Printf("Spectator %s connected. Finding a room.", spectatorWs.RemoteAddr()) 628 | var assignedRoom *OmokRoom 629 | var lastSentBoardState [TotalCells]uint8 // To avoid sending redundant board states 630 | connectionCheckCounter := 0 // Only check connection periodically 631 | 632 | // Move cleanup logic inside the goroutine to prevent race condition 633 | defer func() { 634 | log.Printf("Spectator %s goroutine cleaning up.", spectatorWs.RemoteAddr()) 635 | if assignedRoom != nil { 636 | removeSocketFromSpectators(assignedRoom, spectatorWs) 637 | } 638 | handleConnectionFailure(spectatorWs) 639 | }() 640 | 641 | for { 642 | // Only check connection every 10 iterations to reduce load 643 | connectionCheckCounter++ 644 | if connectionCheckCounter%10 == 0 && !IsWebSocketConnected(spectatorWs) { 645 | log.Printf("Spectator %s disconnected while waiting/watching.", spectatorWs.RemoteAddr()) 646 | return // Exit goroutine, defer will handle cleanup 647 | } 648 | 649 | globalMutex.Lock() 650 | foundRoomThisCycle := false 651 | for _, room := range rooms { 652 | if room.user1.check && room.user2.check { // Active game 653 | if assignedRoom != room { // New room or first assignment 654 | if assignedRoom != nil { // Was watching another room 655 | removeSocketFromSpectators(assignedRoom, spectatorWs) 656 | } 657 | assignedRoom = room 658 | addSocketToSpectators(assignedRoom, spectatorWs) 659 | log.Printf("Spectator %s now watching game between %s and %s.", spectatorWs.RemoteAddr(), room.user1.nickname, room.user2.nickname) 660 | 661 | // Send full initial state for the new room 662 | assignedRoom.spectatorsMux.Lock() // Lock before accessing room board 663 | boardCopy := assignedRoom.board_15x15 // Make a copy to send 664 | lastSentBoardState = boardCopy 665 | assignedRoom.spectatorsMux.Unlock() 666 | 667 | initialMsg := SpectatorMessage{ 668 | Board: boardCopy, 669 | User1: room.user1.nickname, 670 | User2: room.user2.nickname, 671 | } 672 | if err := spectatorWs.WriteJSON(initialMsg); err != nil { 673 | log.Printf("Error sending initial state to spectator %s: %v", spectatorWs.RemoteAddr(), err) 674 | return // Exit goroutine, defer will handle cleanup 675 | } 676 | } else { // Still watching the same room, check if board updated 677 | assignedRoom.spectatorsMux.Lock() 678 | boardChanged := false 679 | if assignedRoom.board_15x15 != lastSentBoardState { 680 | boardChanged = true 681 | lastSentBoardState = assignedRoom.board_15x15 682 | } 683 | boardCopy := lastSentBoardState // Use the latest state 684 | assignedRoom.spectatorsMux.Unlock() 685 | 686 | if boardChanged { 687 | updateMsg := SpectatorMessage{Board: boardCopy} 688 | if err := spectatorWs.WriteJSON(updateMsg); err != nil { 689 | log.Printf("Error sending board update to spectator %s: %v", spectatorWs.RemoteAddr(), err) 690 | return // Exit goroutine, defer will handle cleanup 691 | } 692 | } 693 | } 694 | foundRoomThisCycle = true 695 | break // Found a room to spectate 696 | } 697 | } 698 | globalMutex.Unlock() 699 | 700 | if !foundRoomThisCycle { // No active games 701 | if assignedRoom != nil { // Was watching a room that ended 702 | log.Printf("Game ended for spectator %s. Searching for new game.", spectatorWs.RemoteAddr()) 703 | removeSocketFromSpectators(assignedRoom, spectatorWs) 704 | assignedRoom = nil 705 | } 706 | if err := spectatorWs.WriteJSON(Message{Message: "No active games to spectate. Waiting..."}); err != nil { 707 | log.Printf("Error sending waiting message to spectator %s: %v", spectatorWs.RemoteAddr(), err) 708 | return // Exit goroutine, defer will handle cleanup 709 | } 710 | } 711 | 712 | // Keep-alive or periodic check for client pongs if not relying on ReadMessage 713 | // For now, ReadMessage (even if expecting no data) can detect closure. 714 | // Or, rely on IsWebSocketConnected check at the start of the loop. 715 | // To make it more responsive to client closing, try a non-blocking read or short-timeout read. 716 | // The current IsWebSocketConnected sends a ping and expects a pong. 717 | // If we don't expect messages from spectator, ReadMessage will block. 718 | // The IsWebSocketConnected check at loop start is the main liveness check. 719 | time.Sleep(2 * time.Second) // Poll for new games or game state changes 720 | } 721 | }(ws) 722 | } 723 | 724 | // Checks if a WebSocket connection is still active by sending an application-level ping. 725 | func IsWebSocketConnected(conn *websocket.Conn) bool { 726 | if conn == nil { 727 | return false 728 | } 729 | 730 | // First, try a simple write operation to test if the connection is still alive 731 | // This is less intrusive than ping/pong and more reliable 732 | conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) 733 | err := conn.WriteJSON(map[string]interface{}{"type": "heartbeat"}) 734 | conn.SetWriteDeadline(time.Time{}) // Clear write deadline 735 | 736 | if err != nil { 737 | log.Printf("IsWebSocketConnected: Connection test failed for %s: %v", conn.RemoteAddr(), err) 738 | return false 739 | } 740 | 741 | // If write succeeds, assume connection is good 742 | // Don't require a response as spectators may not send responses 743 | return true 744 | } 745 | 746 | func addSocketToSpectators(room *OmokRoom, ws *websocket.Conn) { 747 | if room == nil || ws == nil { 748 | return 749 | } 750 | room.spectatorsMux.Lock() 751 | defer room.spectatorsMux.Unlock() 752 | // Avoid duplicates 753 | for _, existingWs := range room.spectators { 754 | if existingWs == ws { 755 | return 756 | } 757 | } 758 | room.spectators = append(room.spectators, ws) 759 | } 760 | 761 | func removeSocketFromSpectators(room *OmokRoom, wsToRemove *websocket.Conn) { 762 | if room == nil || wsToRemove == nil { 763 | return 764 | } 765 | room.spectatorsMux.Lock() 766 | defer room.spectatorsMux.Unlock() 767 | 768 | newSpectators := []*websocket.Conn{} 769 | for _, ws := range room.spectators { 770 | if ws != wsToRemove { 771 | newSpectators = append(newSpectators, ws) 772 | } 773 | } 774 | room.spectators = newSpectators 775 | } 776 | 777 | // Removes a WebSocket from the global 'sockets' list. Assumes globalMutex is held by caller. 778 | func removeWebSocketFromSocketsUnsafe(wsToRemove *websocket.Conn) { 779 | if wsToRemove == nil { 780 | return 781 | } 782 | newSockets := []*websocket.Conn{} 783 | for _, ws := range sockets { 784 | if ws != wsToRemove { 785 | newSockets = append(newSockets, ws) 786 | } 787 | } 788 | sockets = newSockets 789 | log.Printf("Socket %s removed. Current total sockets: %d", wsToRemove.RemoteAddr(), len(sockets)) 790 | } 791 | 792 | // Health check handler 793 | func HealthHandler(w http.ResponseWriter, r *http.Request) { 794 | w.WriteHeader(http.StatusOK) 795 | w.Write([]byte("OK")) 796 | } 797 | 798 | // Removes rooms with disconnected waiting players. Assumes globalMutex is held by caller. 799 | func cleanupDisconnectedWaitingPlayers() { 800 | newRooms := []*OmokRoom{} 801 | connectionsDecremented := 0 802 | 803 | for _, room := range rooms { 804 | keepRoom := true 805 | 806 | // Check if room has only user1 waiting and they're disconnected 807 | if room.user1.check && !room.user2.check { 808 | // Check for nil WebSocket first 809 | if room.user1.ws == nil { 810 | log.Printf("Removing room with nil WebSocket for waiting player: %s", room.user1.nickname) 811 | keepRoom = false 812 | } else { 813 | // Check if this WebSocket is still in our global list 814 | stillInGlobalList := false 815 | for _, socket := range sockets { 816 | if socket == room.user1.ws { 817 | stillInGlobalList = true 818 | break 819 | } 820 | } 821 | 822 | if !stillInGlobalList { 823 | log.Printf("Removing room with WebSocket not in global list for waiting player: %s", room.user1.nickname) 824 | room.user1.ws.Close() 825 | keepRoom = false 826 | } else { 827 | // Do a very lightweight connection test using ping control frame 828 | room.user1.ws.SetWriteDeadline(time.Now().Add(50 * time.Millisecond)) 829 | err := room.user1.ws.WriteMessage(websocket.PingMessage, []byte{}) 830 | room.user1.ws.SetWriteDeadline(time.Time{}) 831 | 832 | if err != nil { 833 | log.Printf("Removing room with unresponsive waiting player: %s (error: %v)", room.user1.nickname, err) 834 | // Close the connection and clean up 835 | room.user1.ws.Close() 836 | removeWebSocketFromSocketsUnsafe(room.user1.ws) 837 | connectionsCount-- 838 | connectionsDecremented++ 839 | keepRoom = false 840 | } 841 | } 842 | } 843 | } 844 | 845 | if keepRoom { 846 | newRooms = append(newRooms, room) 847 | } 848 | } 849 | 850 | rooms = newRooms 851 | 852 | if connectionsDecremented > 0 { 853 | log.Printf("Cleanup: removed %d disconnected waiting players, current connections: %d", connectionsDecremented, connectionsCount) 854 | BroadcastConnectionsCountUnsafe(connectionsCount) 855 | } 856 | } 857 | 858 | // Background cleanup for disconnected waiting players 859 | func backgroundCleanup() { 860 | ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds 861 | defer ticker.Stop() 862 | 863 | for range ticker.C { 864 | globalMutex.Lock() 865 | initialRoomCount := len(rooms) 866 | initialConnectionCount := connectionsCount 867 | 868 | cleanupDisconnectedWaitingPlayers() 869 | 870 | finalRoomCount := len(rooms) 871 | finalConnectionCount := connectionsCount 872 | 873 | if initialRoomCount != finalRoomCount || initialConnectionCount != finalConnectionCount { 874 | log.Printf("Background cleanup: rooms %d->%d, connections %d->%d", 875 | initialRoomCount, finalRoomCount, initialConnectionCount, finalConnectionCount) 876 | } 877 | globalMutex.Unlock() 878 | } 879 | } 880 | 881 | // Main function 882 | func main() { 883 | // Start background cleanup for disconnected waiting players 884 | go backgroundCleanup() 885 | 886 | http.HandleFunc("/health", HealthHandler) 887 | http.HandleFunc("/game", SocketHandler) 888 | http.HandleFunc("/spectator", SpectatorHandler) 889 | 890 | log.Println("Omok server starting on", serverAddress) 891 | if err := http.ListenAndServe(serverAddress, nil); err != nil { 892 | log.Fatalf("Failed to start server: %v", err) 893 | } 894 | } 895 | --------------------------------------------------------------------------------