a geicko-2 based round robin ranking system designed to test c++ battleship submissions battleship.dunkirk.sh

feat: add reasons why stuff failed

dunkirk.sh dd61034b c3551d29

verified
Changed files
+56 -44
internal
runner
server
storage
+18 -17
internal/runner/runner.go
···
return nil
}
-
func RunHeadToHead(player1, player2 storage.Submission, numGames int) (int, int, int) {
re := regexp.MustCompile(`memory_functions_(\w+)\.cpp`)
matches1 := re.FindStringSubmatch(player1.Filename)
matches2 := re.FindStringSubmatch(player2.Filename)
if len(matches1) < 2 || len(matches2) < 2 {
-
return 0, 0, 0
}
prefix1 := matches1[1]
···
// Ensure both files exist in engine/src (copy from uploads if missing)
if _, err := os.Stat(cpp1Path); os.IsNotExist(err) {
log.Printf("Player1 file missing in engine/src, skipping: %s", cpp1Path)
-
return 0, 0, 0
}
if _, err := os.Stat(cpp2Path); os.IsNotExist(err) {
log.Printf("Player2 file missing in engine/src, skipping: %s", cpp2Path)
-
return 0, 0, 0
}
cpp1Content, err := os.ReadFile(cpp1Path)
if err != nil {
log.Printf("Failed to read %s: %v", cpp1Path, err)
-
return 0, 0, 0
}
cpp2Content, err := os.ReadFile(cpp2Path)
if err != nil {
log.Printf("Failed to read %s: %v", cpp2Path, err)
-
return 0, 0, 0
}
suffix1, err := parseFunctionNames(string(cpp1Content))
if err != nil {
log.Printf("Failed to parse function names for %s: %v", player1.Filename, err)
-
return 0, 0, 0
}
suffix2, err := parseFunctionNames(string(cpp2Content))
if err != nil {
log.Printf("Failed to parse function names for %s: %v", player2.Filename, err)
-
return 0, 0, 0
}
buildDir := filepath.Join(enginePath, "build")
···
mainPath := filepath.Join(enginePath, "src", fmt.Sprintf("match_%s_vs_%s.cpp", prefix1, prefix2))
if err := os.WriteFile(mainPath, []byte(mainContent), 0644); err != nil {
log.Printf("Failed to write match main: %v", err)
-
return 0, 0, 0
}
// Compile match binary in sandbox with 120 second timeout
···
output, err := runSandboxed(context.Background(), "compile-match", compileArgs, 120)
if err != nil {
log.Printf("Failed to compile match binary (err=%v): %s", err, output)
-
return 0, 0, 0
}
log.Printf("Match compilation output: %s", output)
···
// Check if binary was actually created
if _, err := os.Stat(combinedBinary); os.IsNotExist(err) {
log.Printf("Match binary was not created at %s, compilation succeeded but no binary found", combinedBinary)
-
return 0, 0, 0
}
// Run match in sandbox with 300 second timeout (1000 games should be ~60s, give headroom)
···
output, err = runSandboxed(context.Background(), "run-match", runArgs, 300)
if err != nil {
log.Printf("Match execution failed: %v\n%s", err, output)
-
return 0, 0, 0
}
-
return parseMatchOutput(string(output))
}
func RunRoundRobinMatches(newSub storage.Submission, uploadDir string, broadcastFunc func(string, int, int, time.Time, []string)) {
···
queuedPlayers := storage.GetQueuedPlayerNames()
broadcastFunc(newSub.Username, matchNum, totalMatches, startTime, queuedPlayers)
-
player1Wins, player2Wins, totalMoves := RunHeadToHead(newSub, opponent, 1000)
-
// If match failed (returned 0-0-0), mark submission as compilation_failed
if player1Wins == 0 && player2Wins == 0 && totalMoves == 0 {
-
log.Printf("❌ Match failed for %s vs %s - marking as compilation_failed", newSub.Username, opponent.Username)
-
storage.UpdateSubmissionStatus(newSub.ID, "compilation_failed")
return
}
···
return nil
}
+
func RunHeadToHead(player1, player2 storage.Submission, numGames int) (int, int, int, string) {
re := regexp.MustCompile(`memory_functions_(\w+)\.cpp`)
matches1 := re.FindStringSubmatch(player1.Filename)
matches2 := re.FindStringSubmatch(player2.Filename)
if len(matches1) < 2 || len(matches2) < 2 {
+
return 0, 0, 0, "Invalid filename format"
}
prefix1 := matches1[1]
···
// Ensure both files exist in engine/src (copy from uploads if missing)
if _, err := os.Stat(cpp1Path); os.IsNotExist(err) {
log.Printf("Player1 file missing in engine/src, skipping: %s", cpp1Path)
+
return 0, 0, 0, fmt.Sprintf("File missing: %s", cpp1Path)
}
if _, err := os.Stat(cpp2Path); os.IsNotExist(err) {
log.Printf("Player2 file missing in engine/src, skipping: %s", cpp2Path)
+
return 0, 0, 0, fmt.Sprintf("Opponent file missing: %s", cpp2Path)
}
cpp1Content, err := os.ReadFile(cpp1Path)
if err != nil {
log.Printf("Failed to read %s: %v", cpp1Path, err)
+
return 0, 0, 0, fmt.Sprintf("Failed to read file: %v", err)
}
cpp2Content, err := os.ReadFile(cpp2Path)
if err != nil {
log.Printf("Failed to read %s: %v", cpp2Path, err)
+
return 0, 0, 0, fmt.Sprintf("Failed to read opponent file: %v", err)
}
suffix1, err := parseFunctionNames(string(cpp1Content))
if err != nil {
log.Printf("Failed to parse function names for %s: %v", player1.Filename, err)
+
return 0, 0, 0, fmt.Sprintf("Could not find required function signatures (initMemory, smartMove, updateMemory)")
}
suffix2, err := parseFunctionNames(string(cpp2Content))
if err != nil {
log.Printf("Failed to parse function names for %s: %v", player2.Filename, err)
+
return 0, 0, 0, fmt.Sprintf("Opponent file parse error: %v", err)
}
buildDir := filepath.Join(enginePath, "build")
···
mainPath := filepath.Join(enginePath, "src", fmt.Sprintf("match_%s_vs_%s.cpp", prefix1, prefix2))
if err := os.WriteFile(mainPath, []byte(mainContent), 0644); err != nil {
log.Printf("Failed to write match main: %v", err)
+
return 0, 0, 0, fmt.Sprintf("Failed to write match file: %v", err)
}
// Compile match binary in sandbox with 120 second timeout
···
output, err := runSandboxed(context.Background(), "compile-match", compileArgs, 120)
if err != nil {
log.Printf("Failed to compile match binary (err=%v): %s", err, output)
+
return 0, 0, 0, fmt.Sprintf("Compilation error: %s", string(output))
}
log.Printf("Match compilation output: %s", output)
···
// Check if binary was actually created
if _, err := os.Stat(combinedBinary); os.IsNotExist(err) {
log.Printf("Match binary was not created at %s, compilation succeeded but no binary found", combinedBinary)
+
return 0, 0, 0, "Match binary not created after compilation"
}
// Run match in sandbox with 300 second timeout (1000 games should be ~60s, give headroom)
···
output, err = runSandboxed(context.Background(), "run-match", runArgs, 300)
if err != nil {
log.Printf("Match execution failed: %v\n%s", err, output)
+
return 0, 0, 0, fmt.Sprintf("Runtime error: %s (possible crash, timeout, or infinite loop)", strings.TrimSpace(string(output)))
}
+
p1, p2, moves := parseMatchOutput(string(output))
+
return p1, p2, moves, ""
}
func RunRoundRobinMatches(newSub storage.Submission, uploadDir string, broadcastFunc func(string, int, int, time.Time, []string)) {
···
queuedPlayers := storage.GetQueuedPlayerNames()
broadcastFunc(newSub.Username, matchNum, totalMatches, startTime, queuedPlayers)
+
player1Wins, player2Wins, totalMoves, errMsg := RunHeadToHead(newSub, opponent, 1000)
+
// If match failed (returned 0-0-0), mark submission as match_failed with error message
if player1Wins == 0 && player2Wins == 0 && totalMoves == 0 {
+
log.Printf("❌ Match execution failed for %s vs %s - marking as match_failed", newSub.Username, opponent.Username)
+
storage.UpdateSubmissionStatusWithMessage(newSub.ID, "match_failed", errMsg)
return
}
+1 -1
internal/runner/worker.go
···
if err := CompileSubmission(sub, uploadDir); err != nil {
log.Printf("❌ Compilation failed for %s: %v", sub.Username, err)
-
storage.UpdateSubmissionStatus(sub.ID, "compilation_failed")
notifyFunc()
continue
}
···
if err := CompileSubmission(sub, uploadDir); err != nil {
log.Printf("❌ Compilation failed for %s: %v", sub.Username, err)
+
storage.UpdateSubmissionStatusWithMessage(sub.ID, "compilation_failed", err.Error())
notifyFunc()
continue
}
+2 -2
internal/server/web.go
···
{{range $i, $e := .Entries}}
<tr{{if $e.IsPending}} class="pending"{{else if $e.IsBroken}} class="broken"{{end}}>
<td class="rank rank-{{add $i 1}}">{{if $e.IsBroken}}💥{{else if $e.IsPending}}⏳{{else if lt $i 3}}{{medal $i}}{{else}}{{add $i 1}}{{end}}</td>
-
<td class="player-name"><a href="/user/{{$e.Username}}" style="color: inherit; text-decoration: none;">{{$e.Username}}{{if $e.IsPending}} <span style="font-size: 0.8em;">(pending)</span>{{else if $e.IsBroken}} <span style="font-size: 0.8em; color: #ef4444;">(compilation failed)</span>{{end}}</a></td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}<strong>{{$e.Rating}}</strong> <span style="color: #94a3b8; font-size: 0.85em;">±{{$e.RD}}</span>{{end}}</td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}{{$e.Wins}}{{end}}</td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}{{$e.Losses}}{{end}}</td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}<span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span>{{end}}</td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}{{printf "%.1f" $e.AvgMoves}}{{end}}</td>
-
<td style="color: #64748b;">{{if $e.IsPending}}Waiting...{{else if $e.IsBroken}}Failed{{else}}{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}{{end}}</td>
</tr>
{{end}}
{{else}}
···
{{range $i, $e := .Entries}}
<tr{{if $e.IsPending}} class="pending"{{else if $e.IsBroken}} class="broken"{{end}}>
<td class="rank rank-{{add $i 1}}">{{if $e.IsBroken}}💥{{else if $e.IsPending}}⏳{{else if lt $i 3}}{{medal $i}}{{else}}{{add $i 1}}{{end}}</td>
+
<td class="player-name"><a href="/user/{{$e.Username}}" style="color: inherit; text-decoration: none;">{{$e.Username}}{{if $e.IsPending}} <span style="font-size: 0.8em;">(pending)</span>{{else if $e.IsBroken}} <span style="font-size: 0.8em; color: #ef4444;" title="{{$e.FailureMessage}}">(failed)</span>{{end}}</a></td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}<strong>{{$e.Rating}}</strong> <span style="color: #94a3b8; font-size: 0.85em;">±{{$e.RD}}</span>{{end}}</td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}{{$e.Wins}}{{end}}</td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}{{$e.Losses}}{{end}}</td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}<span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span>{{end}}</td>
<td>{{if or $e.IsPending $e.IsBroken}}-{{else}}{{printf "%.1f" $e.AvgMoves}}{{end}}</td>
+
<td style="color: #64748b;">{{if $e.IsPending}}Waiting...{{else if $e.IsBroken}}<span title="{{$e.FailureMessage}}">Failed</span>{{else}}{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}{{end}}</td>
</tr>
{{end}}
{{else}}
+35 -24
internal/storage/database.go
···
var DB *sql.DB
type LeaderboardEntry struct {
-
Username string
-
Wins int
-
Losses int
-
WinPct float64
-
Rating int
-
RD int
-
AvgMoves float64
-
Stage string
-
LastPlayed time.Time
-
IsPending bool
-
IsBroken bool
}
type Submission struct {
-
ID int
-
Username string
-
Filename string
-
UploadTime time.Time
-
Status string
-
IsActive bool
}
type SubmissionWithStats struct {
···
is_active BOOLEAN DEFAULT 1,
glicko_rating REAL DEFAULT 1500.0,
glicko_rd REAL DEFAULT 350.0,
-
glicko_volatility REAL DEFAULT 0.06
);
CREATE TABLE IF NOT EXISTS tournaments (
···
AVG(CASE WHEN m.player1_id = s.id THEN m.player1_moves ELSE m.player2_moves END) as avg_moves,
MAX(m.timestamp) as last_played,
0 as is_pending,
-
0 as is_broken
FROM submissions s
LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1
-
WHERE s.is_active = 1 AND s.status NOT IN ('compilation_failed')
GROUP BY s.username, s.glicko_rating, s.glicko_rd
HAVING COUNT(m.id) > 0
···
999.0 as avg_moves,
s.upload_time as last_played,
1 as is_pending,
-
0 as is_broken
FROM submissions s
LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1
WHERE s.is_active = 1 AND s.status IN ('pending', 'testing', 'completed')
···
999.0 as avg_moves,
s.upload_time as last_played,
0 as is_pending,
-
1 as is_broken
FROM submissions s
-
WHERE s.is_active = 1 AND s.status = 'compilation_failed'
ORDER BY is_broken ASC, is_pending ASC, rating DESC, total_wins DESC, avg_moves ASC
LIMIT ?
···
var lastPlayed string
var rating, rd float64
var isPending, isBroken int
-
err := rows.Scan(&e.Username, &rating, &rd, &e.Wins, &e.Losses, &e.AvgMoves, &lastPlayed, &isPending, &isBroken)
if err != nil {
return nil, err
}
···
func UpdateSubmissionStatus(id int, status string) error {
_, err := DB.Exec("UPDATE submissions SET status = ? WHERE id = ?", status, id)
return err
}
···
var DB *sql.DB
type LeaderboardEntry struct {
+
Username string
+
Wins int
+
Losses int
+
WinPct float64
+
Rating int
+
RD int
+
AvgMoves float64
+
Stage string
+
LastPlayed time.Time
+
IsPending bool
+
IsBroken bool
+
FailureMessage string
}
type Submission struct {
+
ID int
+
Username string
+
Filename string
+
UploadTime time.Time
+
Status string
+
IsActive bool
+
FailureMessage string
}
type SubmissionWithStats struct {
···
is_active BOOLEAN DEFAULT 1,
glicko_rating REAL DEFAULT 1500.0,
glicko_rd REAL DEFAULT 350.0,
+
glicko_volatility REAL DEFAULT 0.06,
+
failure_message TEXT
);
CREATE TABLE IF NOT EXISTS tournaments (
···
AVG(CASE WHEN m.player1_id = s.id THEN m.player1_moves ELSE m.player2_moves END) as avg_moves,
MAX(m.timestamp) as last_played,
0 as is_pending,
+
0 as is_broken,
+
'' as failure_message
FROM submissions s
LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1
+
WHERE s.is_active = 1 AND s.status NOT IN ('compilation_failed', 'match_failed')
GROUP BY s.username, s.glicko_rating, s.glicko_rd
HAVING COUNT(m.id) > 0
···
999.0 as avg_moves,
s.upload_time as last_played,
1 as is_pending,
+
0 as is_broken,
+
'' as failure_message
FROM submissions s
LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1
WHERE s.is_active = 1 AND s.status IN ('pending', 'testing', 'completed')
···
999.0 as avg_moves,
s.upload_time as last_played,
0 as is_pending,
+
1 as is_broken,
+
COALESCE(s.failure_message, '') as failure_message
FROM submissions s
+
WHERE s.is_active = 1 AND s.status IN ('compilation_failed', 'match_failed')
ORDER BY is_broken ASC, is_pending ASC, rating DESC, total_wins DESC, avg_moves ASC
LIMIT ?
···
var lastPlayed string
var rating, rd float64
var isPending, isBroken int
+
err := rows.Scan(&e.Username, &rating, &rd, &e.Wins, &e.Losses, &e.AvgMoves, &lastPlayed, &isPending, &isBroken, &e.FailureMessage)
if err != nil {
return nil, err
}
···
func UpdateSubmissionStatus(id int, status string) error {
_, err := DB.Exec("UPDATE submissions SET status = ? WHERE id = ?", status, id)
+
return err
+
}
+
+
func UpdateSubmissionStatusWithMessage(id int, status string, message string) error {
+
_, err := DB.Exec("UPDATE submissions SET status = ?, failure_message = ? WHERE id = ?", status, message, id)
return err
}