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

feat: track performance over time

dunkirk.sh 0c969f26 c9e687c7

verified
Changed files
+393 -6
+67 -3
database.go
···
FOREIGN KEY (winner_id) REFERENCES submissions(id)
);
+
CREATE TABLE IF NOT EXISTS rating_history (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
submission_id INTEGER NOT NULL,
+
rating REAL NOT NULL,
+
rd REAL NOT NULL,
+
volatility REAL NOT NULL,
+
match_id INTEGER,
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
FOREIGN KEY (submission_id) REFERENCES submissions(id),
+
FOREIGN KEY (match_id) REFERENCES matches(id)
+
);
+
CREATE INDEX IF NOT EXISTS idx_bracket_matches_tournament ON bracket_matches(tournament_id);
CREATE INDEX IF NOT EXISTS idx_bracket_matches_status ON bracket_matches(status);
CREATE INDEX IF NOT EXISTS idx_tournaments_status ON tournaments(status);
···
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
CREATE INDEX IF NOT EXISTS idx_submissions_active ON submissions(is_active);
CREATE UNIQUE INDEX IF NOT EXISTS idx_matches_unique_pair ON matches(player1_id, player2_id, is_valid) WHERE is_valid = 1;
+
CREATE INDEX IF NOT EXISTS idx_rating_history_submission ON rating_history(submission_id, timestamp);
`
_, err = db.Exec(schema)
···
return result.LastInsertId()
}
-
func addMatch(player1ID, player2ID, winnerID, player1Wins, player2Wins, player1Moves, player2Moves int) error {
-
_, err := globalDB.Exec(
+
func addMatch(player1ID, player2ID, winnerID, player1Wins, player2Wins, player1Moves, player2Moves int) (int64, error) {
+
result, err := globalDB.Exec(
"INSERT INTO matches (player1_id, player2_id, winner_id, player1_wins, player2_wins, player1_moves, player2_moves) VALUES (?, ?, ?, ?, ?, ?, ?)",
player1ID, player2ID, winnerID, player1Wins, player2Wins, player1Moves, player2Moves,
)
-
return err
+
if err != nil {
+
return 0, err
+
}
+
return result.LastInsertId()
}
func updateSubmissionStatus(id int, status string) error {
···
p2New.Rating, p2New.RD, p2New.Volatility, player2ID,
)
return err
+
}
+
+
func recordRatingHistory(submissionID int, matchID int, rating, rd, volatility float64) error {
+
_, err := globalDB.Exec(
+
"INSERT INTO rating_history (submission_id, match_id, rating, rd, volatility) VALUES (?, ?, ?, ?, ?)",
+
submissionID, matchID, rating, rd, volatility,
+
)
+
return err
+
}
+
+
type RatingHistoryPoint struct {
+
Rating int
+
RD int
+
Volatility float64
+
Timestamp time.Time
+
MatchID int
+
}
+
+
func getRatingHistory(submissionID int) ([]RatingHistoryPoint, error) {
+
rows, err := globalDB.Query(`
+
SELECT rating, rd, volatility, timestamp, match_id
+
FROM rating_history
+
WHERE submission_id = ?
+
ORDER BY timestamp ASC
+
`, submissionID)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
var history []RatingHistoryPoint
+
for rows.Next() {
+
var h RatingHistoryPoint
+
var rating, rd float64
+
var matchID sql.NullInt64
+
err := rows.Scan(&rating, &rd, &h.Volatility, &h.Timestamp, &matchID)
+
if err != nil {
+
return nil, err
+
}
+
h.Rating = int(rating)
+
h.RD = int(rd)
+
if matchID.Valid {
+
h.MatchID = int(matchID.Int64)
+
}
+
history = append(history, h)
+
}
+
+
return history, rows.Err()
}
func hasMatchBetween(player1ID, player2ID int) (bool, error) {
+2
main.go
···
mux := http.NewServeMux()
mux.HandleFunc("/", handleLeaderboard)
mux.HandleFunc("/api/leaderboard", handleAPILeaderboard)
+
mux.HandleFunc("/api/rating-history/", handleRatingHistory)
+
mux.HandleFunc("/player/", handlePlayerPage)
// Serve static files
fs := http.FileServer(http.Dir("./static"))
+18 -1
runner.go
···
const enginePath = "./battleship-engine"
+
func recordRatingSnapshot(submissionID, matchID int) {
+
var rating, rd, volatility float64
+
err := globalDB.QueryRow(
+
"SELECT glicko_rating, glicko_rd, glicko_volatility FROM submissions WHERE id = ?",
+
submissionID,
+
).Scan(&rating, &rd, &volatility)
+
+
if err == nil {
+
recordRatingHistory(submissionID, matchID, rating, rd, volatility)
+
}
+
}
+
func processSubmissions() error {
submissions, err := getPendingSubmissions()
if err != nil {
···
}
// Store match result
-
if err := addMatch(newSub.ID, opponent.ID, winnerID, player1Wins, player2Wins, avgMoves, avgMoves); err != nil {
+
matchID, err := addMatch(newSub.ID, opponent.ID, winnerID, player1Wins, player2Wins, avgMoves, avgMoves)
+
if err != nil {
log.Printf("Failed to store match result: %v", err)
} else {
// Update Glicko-2 ratings based on actual win percentages
if err := updateGlicko2Ratings(newSub.ID, opponent.ID, player1Wins, player2Wins); err != nil {
log.Printf("Glicko-2 update failed: %v", err)
+
} else {
+
// Record rating history for both players after update
+
recordRatingSnapshot(newSub.ID, int(matchID))
+
recordRatingSnapshot(opponent.ID, int(matchID))
}
NotifyLeaderboardUpdate()
+306 -2
web.go
···
color: #e2e8f0;
}
+
.player-name a:hover {
+
color: #60a5fa !important;
+
text-decoration: underline !important;
+
}
+
.win-rate {
font-weight: 600;
padding: 0.25rem 0.75rem;
···
return '<tr>' +
'<td class="rank rank-' + rank + '">' + medal + '</td>' +
-
'<td class="player-name">' + e.Username + '</td>' +
+
'<td class="player-name"><a href="/player/' + e.Username + '" style="color: inherit; text-decoration: none;">' + e.Username + '</a></td>' +
'<td><strong>' + e.Rating + '</strong> <span style="color: #94a3b8; font-size: 0.85em;">±' + e.RD + '</span></td>' +
'<td>' + e.Wins.toLocaleString() + '</td>' +
'<td>' + e.Losses.toLocaleString() + '</td>' +
···
{{range $i, $e := .Entries}}
<tr>
<td class="rank rank-{{add $i 1}}">{{if lt $i 3}}{{medal $i}}{{else}}{{add $i 1}}{{end}}</td>
-
<td class="player-name">{{$e.Username}}</td>
+
<td class="player-name"><a href="/player/{{$e.Username}}" style="color: inherit; text-decoration: none;">{{$e.Username}}</a></td>
<td><strong>{{$e.Rating}}</strong> <span style="color: #94a3b8; font-size: 0.85em;">±{{$e.RD}}</span></td>
<td>{{$e.Wins}}</td>
<td>{{$e.Losses}}</td>
···
}
return total / 2 // Each game counted twice (win+loss)
}
+
+
func handleRatingHistory(w http.ResponseWriter, r *http.Request) {
+
// Extract username from URL path /api/rating-history/{username}
+
username := r.URL.Path[len("/api/rating-history/"):]
+
if username == "" {
+
http.Error(w, "Username required", http.StatusBadRequest)
+
return
+
}
+
+
// Get submission ID for this username
+
var submissionID int
+
err := globalDB.QueryRow(
+
"SELECT id FROM submissions WHERE username = ? AND is_active = 1",
+
username,
+
).Scan(&submissionID)
+
+
if err != nil {
+
http.Error(w, "Player not found", http.StatusNotFound)
+
return
+
}
+
+
// Get rating history
+
history, err := getRatingHistory(submissionID)
+
if err != nil {
+
http.Error(w, fmt.Sprintf("Failed to get rating history: %v", err), http.StatusInternalServerError)
+
return
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
json.NewEncoder(w).Encode(history)
+
}
+
+
func handlePlayerPage(w http.ResponseWriter, r *http.Request) {
+
username := r.URL.Path[len("/player/"):]
+
if username == "" {
+
http.Redirect(w, r, "/", http.StatusSeeOther)
+
return
+
}
+
+
tmpl := template.Must(template.New("player").Parse(playerPageHTML))
+
tmpl.Execute(w, map[string]string{"Username": username})
+
}
+
+
const playerPageHTML = `
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<title>{{.Username}} - Battleship Arena</title>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
+
<style>
+
* {
+
margin: 0;
+
padding: 0;
+
box-sizing: border-box;
+
}
+
+
body {
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+
background: #0f172a;
+
color: #e2e8f0;
+
min-height: 100vh;
+
padding: 2rem 1rem;
+
}
+
+
.container {
+
max-width: 1200px;
+
margin: 0 auto;
+
}
+
+
h1 {
+
font-size: 2.5rem;
+
font-weight: 700;
+
margin-bottom: 0.5rem;
+
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
+
-webkit-background-clip: text;
+
-webkit-text-fill-color: transparent;
+
}
+
+
.back-link {
+
display: inline-block;
+
margin-bottom: 2rem;
+
color: #60a5fa;
+
text-decoration: none;
+
font-size: 0.9rem;
+
}
+
+
.back-link:hover {
+
text-decoration: underline;
+
}
+
+
.stats-grid {
+
display: grid;
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+
gap: 1rem;
+
margin-bottom: 2rem;
+
}
+
+
.stat-card {
+
background: #1e293b;
+
border: 1px solid #334155;
+
border-radius: 12px;
+
padding: 1.5rem;
+
}
+
+
.stat-label {
+
font-size: 0.875rem;
+
color: #94a3b8;
+
margin-bottom: 0.5rem;
+
}
+
+
.stat-value {
+
font-size: 2rem;
+
font-weight: 700;
+
color: #60a5fa;
+
}
+
+
.chart-container {
+
background: #1e293b;
+
border: 1px solid #334155;
+
border-radius: 12px;
+
padding: 2rem;
+
margin-bottom: 2rem;
+
}
+
+
.chart-title {
+
font-size: 1.25rem;
+
font-weight: 600;
+
margin-bottom: 1.5rem;
+
color: #e2e8f0;
+
}
+
+
canvas {
+
max-height: 400px;
+
}
+
</style>
+
</head>
+
<body>
+
<div class="container">
+
<a href="/" class="back-link">← Back to Leaderboard</a>
+
<h1>{{.Username}}</h1>
+
<p style="color: #94a3b8; margin-bottom: 2rem;">Player Statistics</p>
+
+
<div class="stats-grid" id="stats-grid">
+
<div class="stat-card">
+
<div class="stat-label">Current Rating</div>
+
<div class="stat-value" id="current-rating">-</div>
+
</div>
+
<div class="stat-card">
+
<div class="stat-label">Rating Deviation</div>
+
<div class="stat-value" id="current-rd">-</div>
+
</div>
+
<div class="stat-card">
+
<div class="stat-label">Win Rate</div>
+
<div class="stat-value" id="win-rate">-</div>
+
</div>
+
<div class="stat-card">
+
<div class="stat-label">Total Matches</div>
+
<div class="stat-value" id="total-matches">-</div>
+
</div>
+
</div>
+
+
<div class="chart-container">
+
<h2 class="chart-title">Rating History</h2>
+
<canvas id="rating-chart"></canvas>
+
</div>
+
+
<div class="chart-container">
+
<h2 class="chart-title">Rating Deviation Over Time</h2>
+
<canvas id="rd-chart"></canvas>
+
</div>
+
</div>
+
+
<script>
+
const username = "{{.Username}}";
+
+
async function loadData() {
+
try {
+
// Load rating history
+
const historyRes = await fetch('/api/rating-history/' + username);
+
const history = await historyRes.json();
+
+
// Load current stats from leaderboard
+
const leaderboardRes = await fetch('/api/leaderboard');
+
const leaderboard = await leaderboardRes.json();
+
const player = leaderboard.find(p => p.Username === username);
+
+
if (player) {
+
document.getElementById('current-rating').textContent = player.Rating + ' ±' + player.RD;
+
document.getElementById('current-rd').textContent = player.RD;
+
document.getElementById('win-rate').textContent = player.WinPct.toFixed(1) + '%';
+
const total = player.Wins + player.Losses;
+
document.getElementById('total-matches').textContent = Math.floor(total / 1000);
+
}
+
+
// Create rating chart
+
const ratingCtx = document.getElementById('rating-chart').getContext('2d');
+
new Chart(ratingCtx, {
+
type: 'line',
+
data: {
+
labels: history.map((h, i) => 'Match ' + (i + 1)),
+
datasets: [{
+
label: 'Rating',
+
data: history.map(h => h.Rating),
+
borderColor: '#60a5fa',
+
backgroundColor: 'rgba(96, 165, 250, 0.1)',
+
tension: 0.1,
+
fill: true
+
}]
+
},
+
options: {
+
responsive: true,
+
maintainAspectRatio: true,
+
plugins: {
+
legend: {
+
display: false
+
}
+
},
+
scales: {
+
y: {
+
beginAtZero: false,
+
grid: {
+
color: '#334155'
+
},
+
ticks: {
+
color: '#94a3b8'
+
}
+
},
+
x: {
+
grid: {
+
color: '#334155'
+
},
+
ticks: {
+
color: '#94a3b8',
+
maxTicksLimit: 10
+
}
+
}
+
}
+
}
+
});
+
+
// Create RD chart
+
const rdCtx = document.getElementById('rd-chart').getContext('2d');
+
new Chart(rdCtx, {
+
type: 'line',
+
data: {
+
labels: history.map((h, i) => 'Match ' + (i + 1)),
+
datasets: [{
+
label: 'Rating Deviation',
+
data: history.map(h => h.RD),
+
borderColor: '#a78bfa',
+
backgroundColor: 'rgba(167, 139, 250, 0.1)',
+
tension: 0.1,
+
fill: true
+
}]
+
},
+
options: {
+
responsive: true,
+
maintainAspectRatio: true,
+
plugins: {
+
legend: {
+
display: false
+
}
+
},
+
scales: {
+
y: {
+
beginAtZero: false,
+
grid: {
+
color: '#334155'
+
},
+
ticks: {
+
color: '#94a3b8'
+
}
+
},
+
x: {
+
grid: {
+
color: '#334155'
+
},
+
ticks: {
+
color: '#94a3b8',
+
maxTicksLimit: 10
+
}
+
}
+
}
+
}
+
});
+
+
} catch (err) {
+
console.error('Failed to load data:', err);
+
}
+
}
+
+
loadData();
+
</script>
+
</body>
+
</html>
+
`
+