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

feat: init

dunkirk.sh 079b13a4

verified
+6
.gitignore
···
+
.ssh/
+
submissions/
+
*.db
+
battleship-arena
+
*.log
+
build/
+51
AGENTS.md
···
+
# Development Notes
+
+
## Architecture
+
+
- **main.go** - SSH/HTTP server initialization with Wish and Bubble Tea
+
- **model.go** - Terminal UI (TUI) for SSH sessions
+
- **database.go** - SQLite storage for submissions and results
+
- **web.go** - HTTP leaderboard with HTML template
+
- **runner.go** - Compiles and tests C++ submissions against battleship library
+
- **scp.go** - SCP upload middleware for file submissions
+
- **worker.go** - Background processor (runs every 30s)
+
+
## File Upload
+
+
Students upload via SCP:
+
```bash
+
scp -P 2222 memory_functions_name.cpp username@host:~/
+
```
+
+
Files must match pattern `memory_functions_*.cpp`
+
+
## Testing Flow
+
+
1. Student uploads file via SCP → saved to `./submissions/username/`
+
2. Student SSH in and selects "Test Submission"
+
3. Worker picks up pending submission
+
4. Compiles with battleship library: `g++ battle_light.cpp battleship_light.cpp memory_functions_*.cpp`
+
5. Runs benchmark: `./battle --benchmark 100`
+
6. Parses results and updates database
+
7. Leaderboard shows updated rankings
+
+
## Configuration
+
+
Edit `runner.go` line 11:
+
```go
+
const battleshipRepoPath = "/path/to/cs1210-battleship"
+
```
+
+
## Building
+
+
```bash
+
make build # Build binary
+
make run # Build and run
+
make gen-key # Generate SSH host key
+
```
+
+
## Deployment
+
+
See `Dockerfile`, `docker-compose.yml`, or `battleship-arena.service` for systemd.
+
+
Web runs on port 8080, SSH on port 2222.
+25
LICENSE.md
···
+
The MIT License (MIT)
+
=====================
+
+
Copyright © `2025` `Kieran Klukas`
+
+
Permission is hereby granted, free of charge, to any person
+
obtaining a copy of this software and associated documentation
+
files (the “Software”), to deal in the Software without
+
restriction, including without limitation the rights to use,
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
+
copies of the Software, and to permit persons to whom the
+
Software is furnished to do so, subject to the following
+
conditions:
+
+
The above copyright notice and this permission notice shall be
+
included in all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+
OTHER DEALINGS IN THE SOFTWARE.
+63
Makefile
···
+
.PHONY: build run clean test docker-build docker-run help
+
+
# Build the battleship arena server
+
build:
+
@echo "Building battleship-arena..."
+
@go build -o battleship-arena
+
+
# Run the server
+
run: build
+
@echo "Starting battleship-arena..."
+
@./battleship-arena
+
+
# Clean build artifacts
+
clean:
+
@echo "Cleaning..."
+
@rm -f battleship-arena
+
@rm -rf submissions/ .ssh/ *.db
+
+
# Run tests
+
test:
+
@echo "Running tests..."
+
@go test -v ./...
+
+
# Generate SSH host key
+
gen-key:
+
@echo "Generating SSH host key..."
+
@mkdir -p .ssh
+
@ssh-keygen -t ed25519 -f .ssh/battleship_arena -N ""
+
+
# Format code
+
fmt:
+
@echo "Formatting code..."
+
@go fmt ./...
+
+
# Lint code
+
lint:
+
@echo "Linting code..."
+
@golangci-lint run
+
+
# Update dependencies
+
deps:
+
@echo "Updating dependencies..."
+
@go mod tidy
+
@go mod download
+
+
# Build for production (optimized)
+
build-prod:
+
@echo "Building for production..."
+
@CGO_ENABLED=1 go build -ldflags="-s -w" -o battleship-arena
+
+
# Show help
+
help:
+
@echo "Available targets:"
+
@echo " build - Build the server"
+
@echo " run - Build and run the server"
+
@echo " clean - Clean build artifacts"
+
@echo " test - Run tests"
+
@echo " gen-key - Generate SSH host key"
+
@echo " fmt - Format code"
+
@echo " lint - Lint code"
+
@echo " deps - Update dependencies"
+
@echo " build-prod - Build optimized production binary"
+
@echo " help - Show this help"
+41
README.md
···
+
# Battleship Arena
+
+
This is a service I made to allow students in my `cs-1210` class to benchmark their battleship programs against each other.
+
+
## I just want to get on the leaderboard; How?
+
+
First ssh into the battleship server and it will ask you a few questions to set up your account. Then scp your battleship file onto the server!
+
+
```bash
+
ssh battleship.dunkirk.sh
+
scp memory_functions_yourname.cpp battleship.dunkirk.sh
+
```
+
+
## Development
+
+
Built with Go using [Wish](https://github.com/charmbracelet/wish), [Bubble Tea](https://github.com/charmbracelet/bubbletea), and [Lipgloss](https://github.com/charmbracelet/lipgloss).
+
+
```bash
+
# Build and run
+
make build
+
make run
+
+
# Generate SSH host key
+
make gen-key
+
```
+
+
See `AGENTS.md` for architecture details.
+
+
The main repo is [the tangled repo](https://tangled.org/dunkirk.sh/battleship-arena) and the github is just a mirror.
+
+
<p align="center">
+
<img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/master/.github/images/line-break.svg" />
+
</p>
+
+
<p align="center">
+
&copy 2025-present <a href="https://github.com/taciturnaxolotl">Kieran Klukas</a>
+
</p>
+
+
<p align="center">
+
<a href="https://github.com/taciturnaxolotl/battleship-arena/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=MIT&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
+
</p>
+140
database.go
···
+
package main
+
+
import (
+
"database/sql"
+
"time"
+
+
_ "github.com/mattn/go-sqlite3"
+
)
+
+
var globalDB *sql.DB
+
+
type LeaderboardEntry struct {
+
Username string
+
Wins int
+
Losses int
+
AvgMoves float64
+
LastPlayed time.Time
+
}
+
+
type Submission struct {
+
ID int
+
Username string
+
Filename string
+
UploadTime time.Time
+
Status string // pending, testing, completed, failed
+
}
+
+
func initDB(path string) (*sql.DB, error) {
+
db, err := sql.Open("sqlite3", path)
+
if err != nil {
+
return nil, err
+
}
+
+
schema := `
+
CREATE TABLE IF NOT EXISTS submissions (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
username TEXT NOT NULL,
+
filename TEXT NOT NULL,
+
upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
status TEXT DEFAULT 'pending'
+
);
+
+
CREATE TABLE IF NOT EXISTS results (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
submission_id INTEGER,
+
opponent TEXT,
+
result TEXT, -- win, loss, tie
+
moves INTEGER,
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
FOREIGN KEY (submission_id) REFERENCES submissions(id)
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_results_submission ON results(submission_id);
+
CREATE INDEX IF NOT EXISTS idx_submissions_username ON submissions(username);
+
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
+
`
+
+
_, err = db.Exec(schema)
+
return db, err
+
}
+
+
func getLeaderboard(limit int) ([]LeaderboardEntry, error) {
+
query := `
+
SELECT
+
s.username,
+
SUM(CASE WHEN r.result = 'win' THEN 1 ELSE 0 END) as wins,
+
SUM(CASE WHEN r.result = 'loss' THEN 1 ELSE 0 END) as losses,
+
AVG(r.moves) as avg_moves,
+
MAX(r.timestamp) as last_played
+
FROM submissions s
+
JOIN results r ON s.id = r.submission_id
+
GROUP BY s.username
+
ORDER BY wins DESC, losses ASC, avg_moves ASC
+
LIMIT ?
+
`
+
+
rows, err := globalDB.Query(query, limit)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
var entries []LeaderboardEntry
+
for rows.Next() {
+
var e LeaderboardEntry
+
err := rows.Scan(&e.Username, &e.Wins, &e.Losses, &e.AvgMoves, &e.LastPlayed)
+
if err != nil {
+
return nil, err
+
}
+
entries = append(entries, e)
+
}
+
+
return entries, rows.Err()
+
}
+
+
func addSubmission(username, filename string) (int64, error) {
+
result, err := globalDB.Exec(
+
"INSERT INTO submissions (username, filename) VALUES (?, ?)",
+
username, filename,
+
)
+
if err != nil {
+
return 0, err
+
}
+
return result.LastInsertId()
+
}
+
+
func addResult(submissionID int, opponent, result string, moves int) error {
+
_, err := globalDB.Exec(
+
"INSERT INTO results (submission_id, opponent, result, moves) VALUES (?, ?, ?, ?)",
+
submissionID, opponent, result, moves,
+
)
+
return err
+
}
+
+
func updateSubmissionStatus(id int, status string) error {
+
_, err := globalDB.Exec("UPDATE submissions SET status = ? WHERE id = ?", status, id)
+
return err
+
}
+
+
func getPendingSubmissions() ([]Submission, error) {
+
rows, err := globalDB.Query(
+
"SELECT id, username, filename, upload_time, status FROM submissions WHERE status = 'pending' ORDER BY upload_time",
+
)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
var submissions []Submission
+
for rows.Next() {
+
var s Submission
+
err := rows.Scan(&s.ID, &s.Username, &s.Filename, &s.UploadTime, &s.Status)
+
if err != nil {
+
return nil, err
+
}
+
submissions = append(submissions, s)
+
}
+
+
return submissions, rows.Err()
+
}
+43
go.mod
···
+
module battleship-arena
+
+
go 1.25.4
+
+
require (
+
github.com/charmbracelet/bubbletea v1.3.10
+
github.com/charmbracelet/lipgloss v1.1.0
+
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
+
github.com/charmbracelet/wish v1.4.7
+
github.com/mattn/go-sqlite3 v1.14.32
+
)
+
+
require (
+
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+
github.com/charmbracelet/keygen v0.5.3 // indirect
+
github.com/charmbracelet/log v0.4.1 // indirect
+
github.com/charmbracelet/x/ansi v0.10.1 // indirect
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+
github.com/charmbracelet/x/conpty v0.1.0 // indirect
+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
+
github.com/charmbracelet/x/input v0.3.4 // indirect
+
github.com/charmbracelet/x/term v0.2.1 // indirect
+
github.com/charmbracelet/x/termios v0.1.0 // indirect
+
github.com/charmbracelet/x/windows v0.2.0 // indirect
+
github.com/creack/pty v1.1.21 // indirect
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+
github.com/go-logfmt/logfmt v0.6.0 // indirect
+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+
github.com/mattn/go-isatty v0.0.20 // indirect
+
github.com/mattn/go-localereader v0.0.1 // indirect
+
github.com/mattn/go-runewidth v0.0.16 // indirect
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+
github.com/muesli/cancelreader v0.2.2 // indirect
+
github.com/muesli/termenv v0.16.0 // indirect
+
github.com/rivo/uniseg v0.4.7 // indirect
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+
golang.org/x/crypto v0.37.0 // indirect
+
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+
golang.org/x/sys v0.36.0 // indirect
+
golang.org/x/text v0.24.0 // indirect
+
)
+85
go.sum
···
+
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
+
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
+
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
+
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
+
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
+
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
+
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
+
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
+
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
+
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+
github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
+
github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U=
+
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
+
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
+
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
+
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
+
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+118
main.go
···
+
package main
+
+
import (
+
"context"
+
"errors"
+
"log"
+
"net/http"
+
"os"
+
"os/signal"
+
"syscall"
+
"time"
+
+
tea "github.com/charmbracelet/bubbletea"
+
"github.com/charmbracelet/lipgloss"
+
"github.com/charmbracelet/ssh"
+
"github.com/charmbracelet/wish"
+
"github.com/charmbracelet/wish/bubbletea"
+
"github.com/charmbracelet/wish/logging"
+
)
+
+
const (
+
host = "0.0.0.0"
+
sshPort = "2222"
+
webPort = "8080"
+
uploadDir = "./submissions"
+
resultsDB = "./results.db"
+
)
+
+
func main() {
+
// Initialize storage
+
if err := initStorage(); err != nil {
+
log.Fatal(err)
+
}
+
+
// Start background worker
+
workerCtx, workerCancel := context.WithCancel(context.Background())
+
defer workerCancel()
+
go startWorker(workerCtx)
+
+
// Start web server
+
go startWebServer()
+
+
// Start SSH server with TUI
+
s, err := wish.NewServer(
+
wish.WithAddress(host + ":" + sshPort),
+
wish.WithHostKeyPath(".ssh/battleship_arena"),
+
wish.WithMiddleware(
+
scpMiddleware(),
+
bubbletea.Middleware(teaHandler),
+
logging.Middleware(),
+
),
+
)
+
if err != nil {
+
log.Fatal(err)
+
}
+
+
done := make(chan os.Signal, 1)
+
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
+
+
log.Printf("SSH server listening on %s:%s", host, sshPort)
+
log.Printf("Web leaderboard at http://%s:%s", host, webPort)
+
+
go func() {
+
if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
+
log.Fatal(err)
+
}
+
}()
+
+
<-done
+
log.Println("Shutting down servers...")
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+
defer cancel()
+
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
+
log.Fatal(err)
+
}
+
}
+
+
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
+
pty, _, active := s.Pty()
+
if !active {
+
wish.Fatalln(s, "no active terminal")
+
return nil, nil
+
}
+
+
m := initialModel(s.User(), pty.Window.Width, pty.Window.Height)
+
return m, []tea.ProgramOption{tea.WithAltScreen()}
+
}
+
+
func initStorage() error {
+
if err := os.MkdirAll(uploadDir, 0755); err != nil {
+
return err
+
}
+
+
db, err := initDB(resultsDB)
+
if err != nil {
+
return err
+
}
+
globalDB = db
+
+
return nil
+
}
+
+
func startWebServer() {
+
mux := http.NewServeMux()
+
mux.HandleFunc("/", handleLeaderboard)
+
mux.HandleFunc("/api/leaderboard", handleAPILeaderboard)
+
+
log.Printf("Web server starting on :%s", webPort)
+
if err := http.ListenAndServe(":"+webPort, mux); err != nil {
+
log.Fatal(err)
+
}
+
}
+
+
var titleStyle = lipgloss.NewStyle().
+
Bold(true).
+
Foreground(lipgloss.Color("205")).
+
MarginTop(1).
+
MarginBottom(1)
+222
model.go
···
+
package main
+
+
import (
+
"fmt"
+
"strings"
+
+
tea "github.com/charmbracelet/bubbletea"
+
"github.com/charmbracelet/lipgloss"
+
)
+
+
type menuChoice int
+
+
const (
+
menuUpload menuChoice = iota
+
menuLeaderboard
+
menuSubmit
+
menuHelp
+
menuQuit
+
)
+
+
type model struct {
+
username string
+
width int
+
height int
+
choice menuChoice
+
submitting bool
+
filename string
+
fileContent []byte
+
message string
+
leaderboard []LeaderboardEntry
+
}
+
+
func initialModel(username string, width, height int) model {
+
return model{
+
username: username,
+
width: width,
+
height: height,
+
choice: menuUpload,
+
}
+
}
+
+
func (m model) Init() tea.Cmd {
+
return loadLeaderboard
+
}
+
+
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+
switch msg := msg.(type) {
+
case tea.KeyMsg:
+
switch msg.String() {
+
case "ctrl+c", "q":
+
return m, tea.Quit
+
case "up", "k":
+
if m.choice > 0 {
+
m.choice--
+
}
+
case "down", "j":
+
if m.choice < menuQuit {
+
m.choice++
+
}
+
case "enter":
+
return m.handleSelection()
+
}
+
case tea.WindowSizeMsg:
+
m.width = msg.Width
+
m.height = msg.Height
+
case leaderboardMsg:
+
m.leaderboard = msg.entries
+
}
+
return m, nil
+
}
+
+
func (m model) handleSelection() (tea.Model, tea.Cmd) {
+
switch m.choice {
+
case menuUpload:
+
m.message = fmt.Sprintf("Upload via SCP:\nscp -P %s memory_functions_yourname.cpp %s@%s:~/", sshPort, m.username, host)
+
return m, nil
+
case menuLeaderboard:
+
return m, loadLeaderboard
+
case menuSubmit:
+
m.message = "Submission queued for testing..."
+
return m, submitForTesting(m.username)
+
case menuHelp:
+
helpText := `Battleship Arena - How to Compete
+
+
1. Create your AI implementation (memory_functions_*.cpp)
+
2. Upload via SCP from your terminal:
+
scp -P ` + sshPort + ` memory_functions_yourname.cpp ` + m.username + `@` + host + `:~/
+
3. Select "Test Submission" to queue your AI for testing
+
4. Check the leaderboard to see your ranking!
+
+
Your AI will be tested against the random AI baseline.
+
Win rate and average moves determine your ranking.`
+
m.message = helpText
+
return m, nil
+
case menuQuit:
+
return m, tea.Quit
+
}
+
return m, nil
+
}
+
+
func (m model) View() string {
+
var b strings.Builder
+
+
title := titleStyle.Render("🚢 Battleship Arena")
+
b.WriteString(title + "\n\n")
+
+
b.WriteString(fmt.Sprintf("User: %s\n\n", m.username))
+
+
// Menu
+
menuStyle := lipgloss.NewStyle().PaddingLeft(2)
+
selectedStyle := lipgloss.NewStyle().
+
Foreground(lipgloss.Color("170")).
+
Bold(true).
+
PaddingLeft(1)
+
+
for i := menuChoice(0); i <= menuQuit; i++ {
+
cursor := " "
+
style := menuStyle
+
if i == m.choice {
+
cursor = ">"
+
style = selectedStyle
+
}
+
b.WriteString(style.Render(fmt.Sprintf("%s %s\n", cursor, menuText(i))))
+
}
+
+
if m.message != "" {
+
b.WriteString("\n" + lipgloss.NewStyle().
+
Foreground(lipgloss.Color("86")).
+
Render(m.message) + "\n")
+
}
+
+
// Show leaderboard if loaded
+
if len(m.leaderboard) > 0 {
+
b.WriteString("\n" + renderLeaderboard(m.leaderboard))
+
}
+
+
b.WriteString("\n\nPress q to quit, ↑/↓ to navigate, enter to select")
+
+
return b.String()
+
}
+
+
func menuText(c menuChoice) string {
+
switch c {
+
case menuUpload:
+
return "Upload Submission"
+
case menuLeaderboard:
+
return "View Leaderboard"
+
case menuSubmit:
+
return "Test Submission"
+
case menuHelp:
+
return "Help"
+
case menuQuit:
+
return "Quit"
+
default:
+
return "Unknown"
+
}
+
}
+
+
type leaderboardMsg struct {
+
entries []LeaderboardEntry
+
}
+
+
func loadLeaderboard() tea.Msg {
+
entries, err := getLeaderboard(20)
+
if err != nil {
+
return leaderboardMsg{entries: nil}
+
}
+
return leaderboardMsg{entries: entries}
+
}
+
+
type submitMsg struct {
+
success bool
+
message string
+
}
+
+
func submitForTesting(username string) tea.Cmd {
+
return func() tea.Msg {
+
// Queue submission for testing
+
if err := queueSubmission(username); err != nil {
+
return submitMsg{success: false, message: err.Error()}
+
}
+
return submitMsg{success: true, message: "Submitted successfully!"}
+
}
+
}
+
+
func renderLeaderboard(entries []LeaderboardEntry) string {
+
if len(entries) == 0 {
+
return "No entries yet"
+
}
+
+
var b strings.Builder
+
b.WriteString(lipgloss.NewStyle().Bold(true).Render("🏆 Leaderboard") + "\n\n")
+
+
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("240"))
+
b.WriteString(headerStyle.Render(fmt.Sprintf("%-4s %-20s %8s %8s %10s\n",
+
"Rank", "User", "Wins", "Losses", "Win Rate")))
+
+
for i, entry := range entries {
+
winRate := 0.0
+
total := entry.Wins + entry.Losses
+
if total > 0 {
+
winRate = float64(entry.Wins) / float64(total) * 100
+
}
+
+
rank := fmt.Sprintf("#%d", i+1)
+
line := fmt.Sprintf("%-4s %-20s %8d %8d %9.2f%%\n",
+
rank, entry.Username, entry.Wins, entry.Losses, winRate)
+
+
style := lipgloss.NewStyle()
+
if i == 0 {
+
style = style.Foreground(lipgloss.Color("220")) // Gold
+
} else if i == 1 {
+
style = style.Foreground(lipgloss.Color("250")) // Silver
+
} else if i == 2 {
+
style = style.Foreground(lipgloss.Color("208")) // Bronze
+
}
+
+
b.WriteString(style.Render(line))
+
}
+
+
return b.String()
+
}
+125
runner.go
···
+
package main
+
+
import (
+
"fmt"
+
"os"
+
"os/exec"
+
"path/filepath"
+
"regexp"
+
"strings"
+
)
+
+
const battleshipRepoPath = "/Users/kierank/code/school/cs1210-battleship"
+
+
func queueSubmission(username string) error {
+
// Find the user's submission file
+
files, err := filepath.Glob(filepath.Join(uploadDir, username, "memory_functions_*.cpp"))
+
if err != nil {
+
return err
+
}
+
if len(files) == 0 {
+
return fmt.Errorf("no submission file found")
+
}
+
+
filename := filepath.Base(files[0])
+
_, err = addSubmission(username, filename)
+
return err
+
}
+
+
func processSubmissions() error {
+
submissions, err := getPendingSubmissions()
+
if err != nil {
+
return err
+
}
+
+
for _, sub := range submissions {
+
if err := testSubmission(sub); err != nil {
+
updateSubmissionStatus(sub.ID, "failed")
+
continue
+
}
+
updateSubmissionStatus(sub.ID, "completed")
+
}
+
+
return nil
+
}
+
+
func testSubmission(sub Submission) error {
+
updateSubmissionStatus(sub.ID, "testing")
+
+
// Copy submission to battleship repo
+
srcPath := filepath.Join(uploadDir, sub.Username, sub.Filename)
+
dstPath := filepath.Join(battleshipRepoPath, "src", sub.Filename)
+
+
input, err := os.ReadFile(srcPath)
+
if err != nil {
+
return err
+
}
+
if err := os.WriteFile(dstPath, input, 0644); err != nil {
+
return err
+
}
+
+
// Extract student ID from filename (memory_functions_NNNN.cpp)
+
re := regexp.MustCompile(`memory_functions_(\w+)\.cpp`)
+
matches := re.FindStringSubmatch(sub.Filename)
+
if len(matches) < 2 {
+
return fmt.Errorf("invalid filename format")
+
}
+
studentID := matches[1]
+
+
// Build the battleship program
+
buildDir := filepath.Join(battleshipRepoPath, "build")
+
os.MkdirAll(buildDir, 0755)
+
+
// Compile using the light version for testing
+
cmd := exec.Command("g++", "-std=c++11", "-O3",
+
"-o", filepath.Join(buildDir, "battle_"+studentID),
+
filepath.Join(battleshipRepoPath, "src", "battle_light.cpp"),
+
filepath.Join(battleshipRepoPath, "src", "battleship_light.cpp"),
+
filepath.Join(battleshipRepoPath, "src", sub.Filename),
+
)
+
output, err := cmd.CombinedOutput()
+
if err != nil {
+
return fmt.Errorf("compilation failed: %s", output)
+
}
+
+
// Run benchmark tests (100 games)
+
cmd = exec.Command(filepath.Join(buildDir, "battle_"+studentID), "--benchmark", "100")
+
output, err = cmd.CombinedOutput()
+
if err != nil {
+
return fmt.Errorf("benchmark failed: %s", output)
+
}
+
+
// Parse results and store in database
+
results := parseResults(string(output))
+
for opponent, result := range results {
+
addResult(sub.ID, opponent, result.Result, result.Moves)
+
}
+
+
return nil
+
}
+
+
type GameResult struct {
+
Result string
+
Moves int
+
}
+
+
func parseResults(output string) map[string]GameResult {
+
results := make(map[string]GameResult)
+
+
// Parse win/loss stats from benchmark output
+
// Example: "Smart AI wins: 95 (95.0%)"
+
lines := strings.Split(output, "\n")
+
for _, line := range lines {
+
if strings.Contains(line, "Smart AI wins:") {
+
// Extract win count
+
re := regexp.MustCompile(`Smart AI wins: (\d+)`)
+
matches := re.FindStringSubmatch(line)
+
if len(matches) >= 2 {
+
// For now, just record as wins against "random"
+
results["random"] = GameResult{Result: "win", Moves: 50}
+
}
+
}
+
}
+
+
return results
+
}
+105
scp.go
···
+
package main
+
+
import (
+
"fmt"
+
"io"
+
"log"
+
"os"
+
"path/filepath"
+
+
"github.com/charmbracelet/ssh"
+
"github.com/charmbracelet/wish"
+
)
+
+
// Add SCP support as a custom middleware
+
func scpMiddleware() wish.Middleware {
+
return func(sh ssh.Handler) ssh.Handler {
+
return func(s ssh.Session) {
+
cmd := s.Command()
+
if len(cmd) > 0 && cmd[0] == "scp" {
+
handleSCP(s, cmd)
+
return
+
}
+
sh(s)
+
}
+
}
+
}
+
+
func handleSCP(s ssh.Session, cmd []string) {
+
// Parse SCP command
+
target := false
+
filename := ""
+
+
for i, arg := range cmd {
+
if arg == "-t" {
+
target = true
+
} else if i == len(cmd)-1 {
+
filename = filepath.Base(arg)
+
}
+
}
+
+
if !target {
+
log.Printf("SCP source mode not supported from %s", s.User())
+
fmt.Fprintf(s, "SCP source mode not supported\n")
+
s.Exit(1)
+
return
+
}
+
+
// Validate filename
+
matched, _ := filepath.Match("memory_functions_*.cpp", filename)
+
if !matched {
+
log.Printf("Invalid filename from %s: %s", s.User(), filename)
+
fmt.Fprintf(s, "Only memory_functions_*.cpp files are accepted\n")
+
s.Exit(1)
+
return
+
}
+
+
// Create user directory
+
userDir := filepath.Join(uploadDir, s.User())
+
if err := os.MkdirAll(userDir, 0755); err != nil {
+
log.Printf("Failed to create user directory: %v", err)
+
s.Exit(1)
+
return
+
}
+
+
// SCP protocol: send 0 byte to indicate ready
+
fmt.Fprintf(s, "\x00")
+
+
// Read SCP header (C0644 size filename)
+
buf := make([]byte, 1024)
+
n, err := s.Read(buf)
+
if err != nil {
+
log.Printf("Failed to read SCP header: %v", err)
+
s.Exit(1)
+
return
+
}
+
+
// Acknowledge header
+
fmt.Fprintf(s, "\x00")
+
+
// Save file
+
dstPath := filepath.Join(userDir, filename)
+
file, err := os.Create(dstPath)
+
if err != nil {
+
log.Printf("Failed to create file: %v", err)
+
s.Exit(1)
+
return
+
}
+
defer file.Close()
+
+
// Read file content
+
_, err = io.Copy(file, io.LimitReader(s, int64(n)))
+
if err != nil && err != io.EOF {
+
log.Printf("Failed to write file: %v", err)
+
s.Exit(1)
+
return
+
}
+
+
// Final acknowledgment
+
fmt.Fprintf(s, "\x00")
+
+
log.Printf("Uploaded %s from %s", filename, s.User())
+
addSubmission(s.User(), filename)
+
+
s.Exit(0)
+
}
+30
scripts/test-submission.sh
···
+
#!/bin/bash
+
# Example test script for submitting and testing an AI
+
+
set -e
+
+
USER="testuser"
+
HOST="localhost"
+
PORT="2222"
+
FILE="memory_functions_test.cpp"
+
+
echo "🚢 Battleship Arena Test Script"
+
echo "================================"
+
+
# Check if submission file exists
+
if [ ! -f "$1" ]; then
+
echo "Usage: $0 <memory_functions_*.cpp>"
+
exit 1
+
fi
+
+
FILE=$(basename "$1")
+
+
echo "📤 Uploading $FILE..."
+
scp -P $PORT "$1" ${USER}@${HOST}:~/
+
+
echo "✅ Upload complete!"
+
echo ""
+
echo "Next steps:"
+
echo "1. SSH into the server: ssh -p $PORT ${USER}@${HOST}"
+
echo "2. Navigate to 'Test Submission' in the menu"
+
echo "3. View results on the leaderboard: http://localhost:8080"
+254
web.go
···
+
package main
+
+
import (
+
"encoding/json"
+
"fmt"
+
"html/template"
+
"net/http"
+
)
+
+
const leaderboardHTML = `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<title>Battleship Arena - Leaderboard</title>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<style>
+
body {
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
+
max-width: 1200px;
+
margin: 0 auto;
+
padding: 20px;
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+
min-height: 100vh;
+
}
+
.container {
+
background: white;
+
border-radius: 12px;
+
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
+
padding: 40px;
+
}
+
h1 {
+
color: #333;
+
text-align: center;
+
margin-bottom: 10px;
+
font-size: 2.5em;
+
}
+
.subtitle {
+
text-align: center;
+
color: #666;
+
margin-bottom: 40px;
+
font-size: 1.1em;
+
}
+
table {
+
width: 100%;
+
border-collapse: collapse;
+
margin-top: 20px;
+
}
+
th {
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+
color: white;
+
padding: 15px;
+
text-align: left;
+
font-weight: 600;
+
}
+
td {
+
padding: 12px 15px;
+
border-bottom: 1px solid #eee;
+
}
+
tr:hover {
+
background: #f8f9fa;
+
}
+
.rank {
+
font-weight: bold;
+
font-size: 1.1em;
+
}
+
.rank-1 { color: #FFD700; }
+
.rank-2 { color: #C0C0C0; }
+
.rank-3 { color: #CD7F32; }
+
.win-rate {
+
font-weight: 600;
+
}
+
.win-rate-high { color: #10b981; }
+
.win-rate-med { color: #f59e0b; }
+
.win-rate-low { color: #ef4444; }
+
.stats {
+
display: flex;
+
justify-content: space-around;
+
margin-top: 40px;
+
padding-top: 30px;
+
border-top: 2px solid #eee;
+
}
+
.stat {
+
text-align: center;
+
}
+
.stat-value {
+
font-size: 2em;
+
font-weight: bold;
+
color: #667eea;
+
}
+
.stat-label {
+
color: #666;
+
margin-top: 5px;
+
}
+
.instructions {
+
background: #f8f9fa;
+
padding: 20px;
+
border-radius: 8px;
+
margin-top: 30px;
+
}
+
.instructions h3 {
+
margin-top: 0;
+
color: #333;
+
}
+
.instructions code {
+
background: #e9ecef;
+
padding: 3px 8px;
+
border-radius: 4px;
+
font-family: 'Monaco', 'Courier New', monospace;
+
}
+
.refresh-note {
+
text-align: center;
+
color: #999;
+
font-size: 0.9em;
+
margin-top: 20px;
+
}
+
</style>
+
<script>
+
// Auto-refresh every 30 seconds
+
setTimeout(() => location.reload(), 30000);
+
</script>
+
</head>
+
<body>
+
<div class="container">
+
<h1>🚢 Battleship Arena</h1>
+
<p class="subtitle">Smart AI Competition Leaderboard</p>
+
+
<table>
+
<thead>
+
<tr>
+
<th>Rank</th>
+
<th>Player</th>
+
<th>Wins</th>
+
<th>Losses</th>
+
<th>Win Rate</th>
+
<th>Avg Moves</th>
+
<th>Last Played</th>
+
</tr>
+
</thead>
+
<tbody>
+
{{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><strong>{{$e.Username}}</strong></td>
+
<td>{{$e.Wins}}</td>
+
<td>{{$e.Losses}}</td>
+
<td class="win-rate {{winRateClass $e}}">{{winRate $e}}%</td>
+
<td>{{printf "%.1f" $e.AvgMoves}}</td>
+
<td>{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}</td>
+
</tr>
+
{{end}}
+
</tbody>
+
</table>
+
+
<div class="stats">
+
<div class="stat">
+
<div class="stat-value">{{.TotalPlayers}}</div>
+
<div class="stat-label">Players</div>
+
</div>
+
<div class="stat">
+
<div class="stat-value">{{.TotalGames}}</div>
+
<div class="stat-label">Games Played</div>
+
</div>
+
</div>
+
+
<div class="instructions">
+
<h3>📤 How to Submit</h3>
+
<p>Upload your battleship AI implementation via SSH:</p>
+
<code>ssh -p 2222 username@localhost</code>
+
<p style="margin-top: 10px;">Then navigate to upload your <code>memory_functions_*.cpp</code> file.</p>
+
</div>
+
+
<p class="refresh-note">Page auto-refreshes every 30 seconds</p>
+
</div>
+
</body>
+
</html>
+
`
+
+
var tmpl = template.Must(template.New("leaderboard").Funcs(template.FuncMap{
+
"add": func(a, b int) int {
+
return a + b
+
},
+
"medal": func(i int) string {
+
medals := []string{"🥇", "🥈", "🥉"}
+
if i < len(medals) {
+
return medals[i]
+
}
+
return ""
+
},
+
"winRate": func(e LeaderboardEntry) string {
+
total := e.Wins + e.Losses
+
if total == 0 {
+
return "0.0"
+
}
+
rate := float64(e.Wins) / float64(total) * 100
+
return formatFloat(rate, 1)
+
},
+
"winRateClass": func(e LeaderboardEntry) string {
+
total := e.Wins + e.Losses
+
if total == 0 {
+
return "win-rate-low"
+
}
+
rate := float64(e.Wins) / float64(total) * 100
+
if rate >= 80 {
+
return "win-rate-high"
+
} else if rate >= 50 {
+
return "win-rate-med"
+
}
+
return "win-rate-low"
+
},
+
}).Parse(leaderboardHTML))
+
+
func formatFloat(f float64, decimals int) string {
+
return fmt.Sprintf("%.1f", f)
+
}
+
+
func handleLeaderboard(w http.ResponseWriter, r *http.Request) {
+
entries, err := getLeaderboard(50)
+
if err != nil {
+
http.Error(w, "Failed to load leaderboard", http.StatusInternalServerError)
+
return
+
}
+
+
data := struct {
+
Entries []LeaderboardEntry
+
TotalPlayers int
+
TotalGames int
+
}{
+
Entries: entries,
+
TotalPlayers: len(entries),
+
TotalGames: calculateTotalGames(entries),
+
}
+
+
tmpl.Execute(w, data)
+
}
+
+
func handleAPILeaderboard(w http.ResponseWriter, r *http.Request) {
+
entries, err := getLeaderboard(50)
+
if err != nil {
+
http.Error(w, "Failed to load leaderboard", http.StatusInternalServerError)
+
return
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
json.NewEncoder(w).Encode(entries)
+
}
+
+
func calculateTotalGames(entries []LeaderboardEntry) int {
+
total := 0
+
for _, e := range entries {
+
total += e.Wins + e.Losses
+
}
+
return total / 2 // Each game counted twice (win+loss)
+
}
+24
worker.go
···
+
package main
+
+
import (
+
"context"
+
"log"
+
"time"
+
)
+
+
// Background worker that processes pending submissions
+
func startWorker(ctx context.Context) {
+
ticker := time.NewTicker(30 * time.Second)
+
defer ticker.Stop()
+
+
for {
+
select {
+
case <-ctx.Done():
+
return
+
case <-ticker.C:
+
if err := processSubmissions(); err != nil {
+
log.Printf("Worker error: %v", err)
+
}
+
}
+
}
+
}