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

feat: add pending submissions

dunkirk.sh 8dc2bbe2 cb3204e8

verified
Changed files
+75 -23
internal
server
storage
battleship-arena

This is a binary file and will not be displayed.

+49 -20
internal/server/web.go
···
transition: background 0.2s;
}
+
tbody tr.pending {
+
opacity: 0.5;
+
color: #64748b;
+
}
+
+
tbody tr.pending .player-name {
+
color: #64748b;
+
}
+
+
tbody tr.pending .rank {
+
color: #64748b !important;
+
}
+
tbody tr:hover {
background: rgba(59, 130, 246, 0.05);
}
···
tbody.innerHTML = entries.map((e, i) => {
const rank = i + 1;
+
const isPending = e.IsPending || false;
+
const rowClass = isPending ? ' class="pending"' : '';
+
+
let rankDisplay;
+
if (isPending) {
+
rankDisplay = '⏳';
+
} else {
+
const medals = ['🥇', '🥈', '🥉'];
+
rankDisplay = medals[i] || rank;
+
}
+
const winRate = e.WinPct.toFixed(1);
const winRateClass = e.WinPct >= 60 ? 'win-rate-high' : e.WinPct >= 40 ? 'win-rate-med' : 'win-rate-low';
-
const medals = ['🥇', '🥈', '🥉'];
-
const medal = medals[i] || rank;
-
const lastPlayed = new Date(e.LastPlayed).toLocaleString('en-US', {
+
const lastPlayed = isPending ? 'Waiting...' : new Date(e.LastPlayed).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
-
return '<tr>' +
-
'<td class="rank rank-' + rank + '">' + medal + '</td>' +
-
'<td class="player-name"><a href="/user/' + 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>' +
-
'<td><span class="win-rate ' + winRateClass + '">' + winRate + '%</span></td>' +
-
'<td>' + e.AvgMoves.toFixed(1) + '</td>' +
+
const nameDisplay = e.Username + (isPending ? ' <span style="font-size: 0.8em;">(pending)</span>' : '');
+
const ratingDisplay = isPending ? '-' : '<strong>' + e.Rating + '</strong> <span style="color: #94a3b8; font-size: 0.85em;">±' + e.RD + '</span>';
+
const winsDisplay = isPending ? '-' : e.Wins.toLocaleString();
+
const lossesDisplay = isPending ? '-' : e.Losses.toLocaleString();
+
const winRateDisplay = isPending ? '-' : '<span class="win-rate ' + winRateClass + '">' + winRate + '%</span>';
+
const avgMovesDisplay = isPending ? '-' : e.AvgMoves.toFixed(1);
+
+
return '<tr' + rowClass + '>' +
+
'<td class="rank rank-' + rank + '">' + rankDisplay + '</td>' +
+
'<td class="player-name"><a href="/user/' + e.Username + '" style="color: inherit; text-decoration: none;">' + nameDisplay + '</a></td>' +
+
'<td>' + ratingDisplay + '</td>' +
+
'<td>' + winsDisplay + '</td>' +
+
'<td>' + lossesDisplay + '</td>' +
+
'<td>' + winRateDisplay + '</td>' +
+
'<td>' + avgMovesDisplay + '</td>' +
'<td style="color: #64748b;">' + lastPlayed + '</td>' +
'</tr>';
}).join('');
···
<tbody>
{{if .Entries}}
{{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"><a href="/user/{{$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>
-
<td><span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span></td>
-
<td>{{printf "%.1f" $e.AvgMoves}}</td>
-
<td style="color: #64748b;">{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}</td>
+
<tr{{if $e.IsPending}} class="pending"{{end}}>
+
<td class="rank rank-{{add $i 1}}">{{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>{{end}}</a></td>
+
<td>{{if $e.IsPending}}-{{else}}<strong>{{$e.Rating}}</strong> <span style="color: #94a3b8; font-size: 0.85em;">±{{$e.RD}}</span>{{end}}</td>
+
<td>{{if $e.IsPending}}-{{else}}{{$e.Wins}}{{end}}</td>
+
<td>{{if $e.IsPending}}-{{else}}{{$e.Losses}}{{end}}</td>
+
<td>{{if $e.IsPending}}-{{else}}<span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span>{{end}}</td>
+
<td>{{if $e.IsPending}}-{{else}}{{printf "%.1f" $e.AvgMoves}}{{end}}</td>
+
<td style="color: #64748b;">{{if $e.IsPending}}Waiting...{{else}}{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}{{end}}</td>
</tr>
{{end}}
{{else}}
+26 -3
internal/storage/database.go
···
AvgMoves float64
Stage string
LastPlayed time.Time
+
IsPending bool
}
type Submission struct {
···
}
func GetLeaderboard(limit int) ([]LeaderboardEntry, error) {
+
// Get submissions with matches
query := `
SELECT
s.username,
···
SUM(CASE WHEN m.player1_id = s.id THEN m.player1_wins WHEN m.player2_id = s.id THEN m.player2_wins ELSE 0 END) as total_wins,
SUM(CASE WHEN m.player1_id = s.id THEN m.player2_wins WHEN m.player2_id = s.id THEN m.player1_wins ELSE 0 END) as total_losses,
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
+
MAX(m.timestamp) as last_played,
+
0 as is_pending
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
GROUP BY s.username, s.glicko_rating, s.glicko_rd
HAVING COUNT(m.id) > 0
-
ORDER BY rating DESC, total_wins DESC
+
+
UNION ALL
+
+
SELECT
+
s.username,
+
1500.0 as rating,
+
350.0 as rd,
+
0 as total_wins,
+
0 as total_losses,
+
0.0 as avg_moves,
+
s.upload_time as last_played,
+
1 as is_pending
+
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')
+
GROUP BY s.username, s.upload_time
+
HAVING COUNT(m.id) = 0
+
+
ORDER BY is_pending ASC, rating DESC, total_wins DESC
LIMIT ?
`
···
var e LeaderboardEntry
var lastPlayed string
var rating, rd float64
-
err := rows.Scan(&e.Username, &rating, &rd, &e.Wins, &e.Losses, &e.AvgMoves, &lastPlayed)
+
var isPending int
+
err := rows.Scan(&e.Username, &rating, &rd, &e.Wins, &e.Losses, &e.AvgMoves, &lastPlayed, &isPending)
if err != nil {
return nil, err
}
e.Rating = int(rating)
e.RD = int(rd)
+
e.IsPending = isPending == 1
totalGames := e.Wins + e.Losses
if totalGames > 0 {