a geicko-2 based round robin ranking system designed to test c++ battleship submissions
battleship.dunkirk.sh
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}