a geicko-2 based round robin ranking system designed to test c++ battleship submissions battleship.dunkirk.sh
at main 19 kB view raw
1package runner 2 3import ( 4 "context" 5 "fmt" 6 "log" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "regexp" 11 "strconv" 12 "strings" 13 "syscall" 14 "time" 15 16 "battleship-arena/internal/storage" 17) 18 19var enginePath = getEnginePath() 20 21func getEnginePath() string { 22 if path := os.Getenv("BATTLESHIP_ENGINE_PATH"); path != "" { 23 return path 24 } 25 return "./battleship-engine" 26} 27 28// runSandboxed executes a command in a systemd-run sandbox with resource limits 29func runSandboxed(ctx context.Context, name string, args []string, timeoutSec int) ([]byte, error) { 30 // Create context with timeout 31 ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) 32 defer cancel() 33 34 // Check if systemd-run is available (not on macOS/local dev) 35 _, err := exec.LookPath("systemd-run") 36 if err != nil { 37 // Fallback: run directly without sandbox (development only) 38 log.Printf("systemd-run not available, running without sandbox: %v", args) 39 cmd := exec.CommandContext(ctx, args[0], args[1:]...) 40 output, err := cmd.CombinedOutput() 41 42 if ctx.Err() == context.DeadlineExceeded { 43 return output, fmt.Errorf("command timed out after %d seconds", timeoutSec) 44 } 45 46 return output, err 47 } 48 49 // Build systemd-run command with security properties 50 // Using service unit (not scope) to get access to network/filesystem isolation 51 systemdArgs := []string{ 52 "--wait", // Wait for service to complete 53 "--pipe", // Pipe stdout/stderr to capture output 54 "--unit=" + name, // Give it a descriptive name 55 "--quiet", // Suppress systemd output 56 "--collect", // Automatically clean up after exit 57 "--service-type=exec", // Run until process exits 58 "--working-directory=/var/lib/battleship-arena", // Ensure proper working directory 59 "--property=MemoryMax=512M", // Max 512MB RAM 60 "--property=CPUQuota=200%", // Max 2 CPU cores worth 61 "--property=TasksMax=50", // Max 50 processes/threads 62 "--property=PrivateNetwork=true", // Isolate network (no internet) 63 "--property=PrivateTmp=true", // Private /tmp 64 "--property=NoNewPrivileges=true", // Prevent privilege escalation 65 "--property=ReadWritePaths=/var/lib/battleship-arena", // Allow writes to battleship directory 66 "--", 67 } 68 systemdArgs = append(systemdArgs, args...) 69 70 cmd := exec.CommandContext(ctx, "systemd-run", systemdArgs...) 71 72 // Set process group for cleanup 73 cmd.SysProcAttr = &syscall.SysProcAttr{ 74 Setpgid: true, 75 } 76 77 output, err := cmd.CombinedOutput() 78 79 // Check for timeout 80 if ctx.Err() == context.DeadlineExceeded { 81 return output, fmt.Errorf("command timed out after %d seconds", timeoutSec) 82 } 83 84 // Check if process was killed by a signal 85 if err != nil { 86 if exitErr, ok := err.(*exec.ExitError); ok { 87 if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { 88 // Direct execution: check if signaled 89 if status.Signaled() { 90 sig := status.Signal() 91 return output, fmt.Errorf("killed by signal: %s", sig.String()) 92 } 93 // systemd-run execution: exit code 128+N means killed by signal N 94 exitCode := status.ExitStatus() 95 if exitCode >= 128 && exitCode <= 192 { 96 sigNum := exitCode - 128 97 sigName := "unknown" 98 switch sigNum { 99 case 1: sigName = "SIGHUP" 100 case 2: sigName = "SIGINT" 101 case 3: sigName = "SIGQUIT" 102 case 4: sigName = "SIGILL" 103 case 5: sigName = "SIGTRAP" 104 case 6: sigName = "SIGABRT" 105 case 7: sigName = "SIGBUS" 106 case 8: sigName = "SIGFPE" 107 case 9: sigName = "SIGKILL" 108 case 10: sigName = "SIGUSR1" 109 case 11: sigName = "SIGSEGV" 110 case 12: sigName = "SIGUSR2" 111 case 13: sigName = "SIGPIPE" 112 case 14: sigName = "SIGALRM" 113 case 15: sigName = "SIGTERM" 114 default: sigName = fmt.Sprintf("signal %d", sigNum) 115 } 116 return output, fmt.Errorf("killed by %s (exit code %d)", sigName, exitCode) 117 } 118 } 119 } 120 } 121 122 return output, err 123} 124 125func CompileSubmission(sub storage.Submission, uploadDir string) error { 126 storage.UpdateSubmissionStatus(sub.ID, "testing") 127 128 re := regexp.MustCompile(`memory_functions_(\w+)\.cpp`) 129 matches := re.FindStringSubmatch(sub.Filename) 130 if len(matches) < 2 { 131 return fmt.Errorf("invalid filename format") 132 } 133 prefix := matches[1] 134 135 buildDir := filepath.Join(enginePath, "build") 136 os.MkdirAll(buildDir, 0755) 137 138 srcDir := filepath.Join(enginePath, "src") 139 os.MkdirAll(srcDir, 0755) 140 141 srcPath := filepath.Join(uploadDir, sub.Username, sub.Filename) 142 dstPath := filepath.Join(enginePath, "src", sub.Filename) 143 144 log.Printf("Copying %s to %s", srcPath, dstPath) 145 input, err := os.ReadFile(srcPath) 146 if err != nil { 147 return err 148 } 149 150 // Students can now use #include "battleship.h" directly 151 // No need to remove it 152 153 if err := os.WriteFile(dstPath, input, 0644); err != nil { 154 return err 155 } 156 157 functionSuffix, err := parseFunctionNames(string(input)) 158 if err != nil { 159 return fmt.Errorf("failed to parse function names: %v", err) 160 } 161 162 log.Printf("Detected function suffix: %s", functionSuffix) 163 164 headerFilename := fmt.Sprintf("memory_functions_%s.h", prefix) 165 headerPath := filepath.Join(enginePath, "src", headerFilename) 166 headerContent := generateHeader(headerFilename, functionSuffix) 167 if err := os.WriteFile(headerPath, []byte(headerContent), 0644); err != nil { 168 return err 169 } 170 171 log.Printf("Compiling submission %d for %s", sub.ID, prefix) 172 173 // Compile in sandbox with 60 second timeout 174 compileArgs := []string{ 175 "g++", "-std=c++11", "-c", "-O3", 176 "-I", filepath.Join(enginePath, "src"), 177 "-o", filepath.Join(buildDir, "ai_"+prefix+".o"), 178 filepath.Join(enginePath, "src", sub.Filename), 179 } 180 181 output, err := runSandboxed(context.Background(), "compile-"+prefix, compileArgs, 60) 182 if err != nil { 183 return fmt.Errorf("compilation failed: %s", output) 184 } 185 186 return nil 187} 188 189func RunHeadToHead(player1, player2 storage.Submission, numGames int) (int, int, int, string) { 190 re := regexp.MustCompile(`memory_functions_(\w+)\.cpp`) 191 matches1 := re.FindStringSubmatch(player1.Filename) 192 matches2 := re.FindStringSubmatch(player2.Filename) 193 194 if len(matches1) < 2 || len(matches2) < 2 { 195 return 0, 0, 0, "Invalid filename format" 196 } 197 198 prefix1 := matches1[1] 199 prefix2 := matches2[1] 200 201 cpp1Path := filepath.Join(enginePath, "src", player1.Filename) 202 cpp2Path := filepath.Join(enginePath, "src", player2.Filename) 203 204 // Ensure both files exist in engine/src (copy from uploads if missing) 205 if _, err := os.Stat(cpp1Path); os.IsNotExist(err) { 206 log.Printf("Player1 file missing in engine/src, skipping: %s", cpp1Path) 207 return 0, 0, 0, fmt.Sprintf("File missing: %s", cpp1Path) 208 } 209 210 if _, err := os.Stat(cpp2Path); os.IsNotExist(err) { 211 log.Printf("Player2 file missing in engine/src, skipping: %s", cpp2Path) 212 return 0, 0, 0, fmt.Sprintf("Opponent file missing: %s", cpp2Path) 213 } 214 215 cpp1Content, err := os.ReadFile(cpp1Path) 216 if err != nil { 217 log.Printf("Failed to read %s: %v", cpp1Path, err) 218 return 0, 0, 0, fmt.Sprintf("Failed to read file: %v", err) 219 } 220 221 cpp2Content, err := os.ReadFile(cpp2Path) 222 if err != nil { 223 log.Printf("Failed to read %s: %v", cpp2Path, err) 224 return 0, 0, 0, fmt.Sprintf("Failed to read opponent file: %v", err) 225 } 226 227 suffix1, err := parseFunctionNames(string(cpp1Content)) 228 if err != nil { 229 log.Printf("Failed to parse function names for %s: %v", player1.Filename, err) 230 return 0, 0, 0, fmt.Sprintf("Could not find required function signatures (initMemory, smartMove, updateMemory)") 231 } 232 233 suffix2, err := parseFunctionNames(string(cpp2Content)) 234 if err != nil { 235 log.Printf("Failed to parse function names for %s: %v", player2.Filename, err) 236 return 0, 0, 0, fmt.Sprintf("Opponent file parse error: %v", err) 237 } 238 239 buildDir := filepath.Join(enginePath, "build") 240 combinedBinary := filepath.Join(buildDir, fmt.Sprintf("match_%s_vs_%s", prefix1, prefix2)) 241 242 mainContent := generateMatchMain(prefix1, prefix2, suffix1, suffix2) 243 mainPath := filepath.Join(enginePath, "src", fmt.Sprintf("match_%s_vs_%s.cpp", prefix1, prefix2)) 244 if err := os.WriteFile(mainPath, []byte(mainContent), 0644); err != nil { 245 log.Printf("Failed to write match main: %v", err) 246 return 0, 0, 0, fmt.Sprintf("Failed to write match file: %v", err) 247 } 248 249 // Compile match binary in sandbox with 120 second timeout 250 compileArgs := []string{"g++"} 251 compileArgs = append(compileArgs, "-std=c++11", "-O3", 252 "-o", combinedBinary, 253 mainPath, 254 filepath.Join(enginePath, "src", "battleship.cpp"), 255 ) 256 257 if prefix1 == prefix2 { 258 compileArgs = append(compileArgs, filepath.Join(enginePath, "src", fmt.Sprintf("memory_functions_%s.cpp", prefix1))) 259 } else { 260 compileArgs = append(compileArgs, 261 filepath.Join(enginePath, "src", fmt.Sprintf("memory_functions_%s.cpp", prefix1)), 262 filepath.Join(enginePath, "src", fmt.Sprintf("memory_functions_%s.cpp", prefix2)), 263 ) 264 } 265 266 output, err := runSandboxed(context.Background(), "compile-match", compileArgs, 120) 267 if err != nil { 268 log.Printf("Failed to compile match binary (err=%v): %s", err, output) 269 return 0, 0, 0, fmt.Sprintf("Compilation error: %s", string(output)) 270 } 271 272 log.Printf("Match compilation output: %s", output) 273 274 // Check if binary was actually created 275 if _, err := os.Stat(combinedBinary); os.IsNotExist(err) { 276 log.Printf("Match binary was not created at %s, compilation succeeded but no binary found", combinedBinary) 277 return 0, 0, 0, "Match binary not created after compilation" 278 } 279 280 // Run match in sandbox with 300 second timeout (1000 games should be ~60s, give headroom) 281 runArgs := []string{combinedBinary, strconv.Itoa(numGames)} 282 output, err = runSandboxed(context.Background(), "run-match", runArgs, 300) 283 if err != nil { 284 log.Printf("Match execution failed: %v\n%s", err, output) 285 errMsg := strings.TrimSpace(string(output)) 286 if errMsg != "" { 287 // If there's output, show it along with the exit status 288 return 0, 0, 0, fmt.Sprintf("Runtime error: %s (%s)", errMsg, err.Error()) 289 } 290 // If no output, just show the error 291 return 0, 0, 0, fmt.Sprintf("Runtime error: %s", err.Error()) 292 } 293 294 p1, p2, moves := parseMatchOutput(string(output)) 295 return p1, p2, moves, "" 296} 297 298func RunRoundRobinMatches(newSub storage.Submission, uploadDir string, broadcastFunc func(string, int, int, time.Time, []string)) { 299 activeSubmissions, err := storage.GetActiveSubmissions() 300 if err != nil { 301 log.Printf("Failed to get active submissions: %v", err) 302 return 303 } 304 305 var unplayedOpponents []storage.Submission 306 for _, opponent := range activeSubmissions { 307 if opponent.ID == newSub.ID { 308 continue 309 } 310 311 hasMatch, err := storage.HasMatchBetween(newSub.ID, opponent.ID) 312 if err != nil { 313 log.Printf("Error checking match history: %v", err) 314 continue 315 } 316 317 if !hasMatch { 318 // Ensure opponent file exists in engine/src 319 opponentSrcPath := filepath.Join(uploadDir, opponent.Username, opponent.Filename) 320 opponentDstPath := filepath.Join(enginePath, "src", opponent.Filename) 321 322 if _, err := os.Stat(opponentDstPath); os.IsNotExist(err) { 323 // Copy opponent file to engine/src 324 opponentContent, err := os.ReadFile(opponentSrcPath) 325 if err != nil { 326 log.Printf("Failed to read opponent file %s: %v", opponentSrcPath, err) 327 continue 328 } 329 if err := os.WriteFile(opponentDstPath, opponentContent, 0644); err != nil { 330 log.Printf("Failed to copy opponent file to engine: %v", err) 331 continue 332 } 333 334 // Generate opponent header if missing 335 re := regexp.MustCompile(`memory_functions_(\w+)\.cpp`) 336 matches := re.FindStringSubmatch(opponent.Filename) 337 if len(matches) >= 2 { 338 prefix := matches[1] 339 functionSuffix, err := parseFunctionNames(string(opponentContent)) 340 if err == nil { 341 headerFilename := fmt.Sprintf("memory_functions_%s.h", prefix) 342 headerPath := filepath.Join(enginePath, "src", headerFilename) 343 headerContent := generateHeader(headerFilename, functionSuffix) 344 os.WriteFile(headerPath, []byte(headerContent), 0644) 345 } 346 } 347 } 348 349 unplayedOpponents = append(unplayedOpponents, opponent) 350 } 351 } 352 353 totalMatches := len(unplayedOpponents) 354 if totalMatches <= 0 { 355 log.Printf("No new opponents for %s, all matches already played", newSub.Username) 356 return 357 } 358 359 log.Printf("Starting round-robin for %s (%d opponents)", newSub.Username, totalMatches) 360 matchNum := 0 361 startTime := time.Now() 362 363 for _, opponent := range unplayedOpponents { 364 matchNum++ 365 366 queuedPlayers := storage.GetQueuedPlayerNames() 367 broadcastFunc(newSub.Username, matchNum, totalMatches, startTime, queuedPlayers) 368 369 player1Wins, player2Wins, totalMoves, errMsg := RunHeadToHead(newSub, opponent, 1000) 370 371 // If match failed (returned 0-0-0), mark submission as match_failed with error message 372 if player1Wins == 0 && player2Wins == 0 && totalMoves == 0 { 373 log.Printf("❌ Match execution failed for %s vs %s - marking as match_failed", newSub.Username, opponent.Username) 374 storage.UpdateSubmissionStatusWithMessage(newSub.ID, "match_failed", errMsg) 375 return 376 } 377 378 var winnerID int 379 avgMoves := totalMoves / 1000 380 381 if player1Wins > player2Wins { 382 winnerID = newSub.ID 383 log.Printf("[%d/%d] %s defeats %s (%d-%d, %d moves avg)", matchNum, totalMatches, newSub.Username, opponent.Username, player1Wins, player2Wins, avgMoves) 384 } else if player2Wins > player1Wins { 385 winnerID = opponent.ID 386 log.Printf("[%d/%d] %s defeats %s (%d-%d, %d moves avg)", matchNum, totalMatches, opponent.Username, newSub.Username, player2Wins, player1Wins, avgMoves) 387 } else { 388 if totalMoves%2 == 0 { 389 winnerID = newSub.ID 390 } else { 391 winnerID = opponent.ID 392 } 393 log.Printf("[%d/%d] Tie %d-%d, coin flip winner: %s", matchNum, totalMatches, player1Wins, player2Wins, 394 map[int]string{newSub.ID: newSub.Username, opponent.ID: opponent.Username}[winnerID]) 395 } 396 397 _, err := storage.AddMatch(newSub.ID, opponent.ID, winnerID, player1Wins, player2Wins, avgMoves, avgMoves) 398 if err != nil { 399 log.Printf("Failed to store match result: %v", err) 400 } 401 } 402 403 log.Printf("✓ Round-robin complete for %s (%d matches)", newSub.Username, totalMatches) 404 405 // Update Glicko-2 ratings using proper rating periods (batch all matches together) 406 log.Printf("Updating Glicko-2 ratings (proper rating period)...") 407 if err := storage.RecalculateAllGlicko2Ratings(); err != nil { 408 log.Printf("Failed to update Glicko-2 ratings: %v", err) 409 } else { 410 log.Printf("✓ Glicko-2 ratings updated") 411 } 412} 413 414func recordRatingSnapshot(submissionID, matchID int) { 415 var rating, rd, volatility float64 416 err := storage.DB.QueryRow( 417 "SELECT glicko_rating, glicko_rd, glicko_volatility FROM submissions WHERE id = ?", 418 submissionID, 419 ).Scan(&rating, &rd, &volatility) 420 421 if err == nil { 422 storage.RecordRatingHistory(submissionID, matchID, rating, rd, volatility) 423 } 424} 425 426func parseFunctionNames(cppContent string) (string, error) { 427 re := regexp.MustCompile(`void\s+initMemory(\w+)\s*\(`) 428 matches := re.FindStringSubmatch(cppContent) 429 if len(matches) < 2 { 430 return "", fmt.Errorf("could not find initMemory function") 431 } 432 return matches[1], nil 433} 434 435func generateHeader(filename, prefix string) string { 436 guard := strings.ToUpper(strings.Replace(filename, ".", "_", -1)) 437 438 return fmt.Sprintf(`#ifndef %s 439#define %s 440 441#include "memory.h" 442#include "battleship.h" 443#include <string> 444 445void initMemory%s(ComputerMemory &memory); 446std::string smartMove%s(const ComputerMemory &memory); 447void updateMemory%s(int row, int col, int result, ComputerMemory &memory); 448 449#endif 450`, guard, guard, prefix, prefix, prefix) 451} 452 453func generateMatchMain(prefix1, prefix2, suffix1, suffix2 string) string { 454 return fmt.Sprintf(`#include "battleship.h" 455#include "memory.h" 456#include "memory_functions_%s.h" 457#include "memory_functions_%s.h" 458#include <iostream> 459#include <cstdlib> 460#include <ctime> 461 462using namespace std; 463 464struct MatchResult { 465 int player1Wins = 0; 466 int player2Wins = 0; 467 int ties = 0; 468 int totalMoves = 0; 469}; 470 471MatchResult runMatch(int numGames) { 472 MatchResult result; 473 srand(time(NULL)); 474 475 for (int game = 0; game < numGames; game++) { 476 Board board1, board2; 477 ComputerMemory memory1, memory2; 478 479 initializeBoard(board1); 480 initializeBoard(board2); 481 initMemory%s(memory1); 482 initMemory%s(memory2); 483 484 int shipsSunk1 = 0; 485 int shipsSunk2 = 0; 486 int moveCount = 0; 487 488 while (true) { 489 moveCount++; 490 491 string move1 = smartMove%s(memory1); 492 int row1, col1; 493 int check1 = checkMove(move1, board2, row1, col1); 494 while (check1 != VALID_MOVE) { 495 move1 = randomMove(); 496 check1 = checkMove(move1, board2, row1, col1); 497 } 498 499 string move2 = smartMove%s(memory2); 500 int row2, col2; 501 int check2 = checkMove(move2, board1, row2, col2); 502 while (check2 != VALID_MOVE) { 503 move2 = randomMove(); 504 check2 = checkMove(move2, board1, row2, col2); 505 } 506 507 int result1 = playMove(row1, col1, board2); 508 int result2 = playMove(row2, col2, board1); 509 510 updateMemory%s(row1, col1, result1, memory1); 511 updateMemory%s(row2, col2, result2, memory2); 512 513 if (isASunk(result1)) shipsSunk1++; 514 if (isASunk(result2)) shipsSunk2++; 515 516 if (shipsSunk1 == 5 || shipsSunk2 == 5) { 517 break; 518 } 519 } 520 521 result.totalMoves += moveCount; 522 523 if (shipsSunk1 == 5 && shipsSunk2 == 5) { 524 result.ties++; 525 } else if (shipsSunk1 == 5) { 526 result.player1Wins++; 527 } else { 528 result.player2Wins++; 529 } 530 } 531 532 return result; 533} 534 535int main(int argc, char* argv[]) { 536 if (argc < 2) { 537 cerr << "Usage: " << argv[0] << " <num_games>" << endl; 538 return 1; 539 } 540 541 int numGames = atoi(argv[1]); 542 if (numGames <= 0) numGames = 10; 543 544 setDebugMode(false); 545 546 MatchResult result = runMatch(numGames); 547 548 cout << "PLAYER1_WINS=" << result.player1Wins << endl; 549 cout << "PLAYER2_WINS=" << result.player2Wins << endl; 550 cout << "TIES=" << result.ties << endl; 551 cout << "TOTAL_MOVES=" << result.totalMoves << endl; 552 cout << "AVG_MOVES=" << (result.totalMoves / numGames) << endl; 553 554 return 0; 555} 556`, prefix1, prefix2, suffix1, suffix2, suffix1, suffix2, suffix1, suffix2) 557} 558 559func parseMatchOutput(output string) (int, int, int) { 560 player1Wins := 0 561 player2Wins := 0 562 totalMoves := 0 563 564 lines := strings.Split(output, "\n") 565 for _, line := range lines { 566 if strings.HasPrefix(line, "PLAYER1_WINS=") { 567 fmt.Sscanf(line, "PLAYER1_WINS=%d", &player1Wins) 568 } else if strings.HasPrefix(line, "PLAYER2_WINS=") { 569 fmt.Sscanf(line, "PLAYER2_WINS=%d", &player2Wins) 570 } else if strings.HasPrefix(line, "TOTAL_MOVES=") { 571 fmt.Sscanf(line, "TOTAL_MOVES=%d", &totalMoves) 572 } 573 } 574 575 return player1Wins, player2Wins, totalMoves 576}