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