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

feat: use chi

dunkirk.sh c00a0be2 36145a07

verified
Changed files
+104 -102
+2 -4
go.mod
···
go 1.25.4
require (
+
github.com/alexandrevicenzi/go-sse v1.6.0
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/go-chi/chi/v5 v5.2.3
github.com/mattn/go-sqlite3 v1.14.32
github.com/pkg/sftp v1.13.10
-
github.com/r3labs/sse/v2 v2.10.0
)
require (
···
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/tmaxmax/go-sse v0.11.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
-
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.28.0 // indirect
-
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)
+4 -17
go.sum
···
+
github.com/alexandrevicenzi/go-sse v1.6.0 h1:3KvOzpuY7UrbqZgAtOEmub9/V5ykr7Myudw+PA+H1Ik=
+
github.com/alexandrevicenzi/go-sse v1.6.0/go.mod h1:jdrNAhMgVqP7OfcUuM8eJx0sOY17wc+girs5utpFZUU=
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
+
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
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/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
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/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=
-
github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/tmaxmax/go-sse v0.11.0 h1:nogmJM6rJUoOLoAwEKeQe5XlVpt9l7N82SS1jI7lWFg=
-
github.com/tmaxmax/go-sse v0.11.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8=
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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
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/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
-
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
-
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
-
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
-
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
-
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
-
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+80 -52
main.go
···
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"github.com/charmbracelet/wish/scp"
+
"github.com/alexandrevicenzi/go-sse"
+
"github.com/go-chi/chi/v5"
+
"github.com/go-chi/chi/v5/middleware"
)
const (
host = "0.0.0.0"
sshPort = "2222"
-
webPort = "8080"
-
ssePort = "8081"
+
webPort = "8081"
uploadDir = "./submissions"
resultsDB = "./results.db"
)
···
log.Fatal(err)
}
-
// Initialize SSE server
-
initSSE()
-
-
// Start SSE server on separate port
-
go startSSEServer()
+
// Initialize SSE server EXACTLY like test
+
s := sse.NewServer(nil)
+
defer s.Shutdown()
+
sseServer = s
// Start background worker
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
go startWorker(workerCtx)
-
// Start web server
-
go startWebServer()
-
-
// Start SSH server with TUI, SCP, and SFTP
+
// Start SSH server in background
toClient, fromClient := newSCPHandlers()
-
s, err := wish.NewServer(
+
sshServer, err := wish.NewServer(
wish.WithAddress(host + ":" + sshPort),
wish.WithHostKeyPath(".ssh/battleship_arena"),
wish.WithSubsystem("sftp", sftpHandler),
···
log.Printf("Web leaderboard at http://%s:%s", host, webPort)
go func() {
-
if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
+
if err := sshServer.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)
-
}
+
// Graceful shutdown handler
+
go func() {
+
<-done
+
log.Println("Shutting down servers...")
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+
defer cancel()
+
if err := sshServer.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
+
log.Fatal(err)
+
}
+
os.Exit(0)
+
}()
+
+
// Start web server EXACTLY like test
+
r := chi.NewRouter()
+
+
// Middleware
+
r.Use(middleware.Logger)
+
r.Use(middleware.Recoverer)
+
+
// SSE endpoint - mounted directly to router
+
r.Mount("/events/", s)
+
+
// API routes
+
r.Get("/api/leaderboard", handleAPILeaderboard)
+
r.Get("/api/rating-history/{player}", handleRatingHistory)
+
+
// Player pages
+
r.Get("/player/{player}", handlePlayerPage)
+
+
// Home page
+
r.Get("/", handleLeaderboard)
+
+
// Static files
+
fs := http.FileServer(http.Dir("./static"))
+
r.Handle("/static/*", http.StripPrefix("/static/", fs))
+
+
log.Println("Server running at http://localhost:" + webPort)
+
http.ListenAndServe(":"+webPort, r)
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
···
func startWebServer() {
mux := http.NewServeMux()
+
+
// SSE endpoint with explicit logging
+
mux.HandleFunc("/events/", func(w http.ResponseWriter, r *http.Request) {
+
log.Printf("SSE request received: %s", r.URL.Path)
+
+
// Try to manually write headers and flush BEFORE SSE library
+
w.Header().Set("Content-Type", "text/event-stream")
+
w.Header().Set("Cache-Control", "no-cache")
+
w.Header().Set("Connection", "keep-alive")
+
w.Header().Set("X-Accel-Buffering", "no")
+
+
log.Printf("Headers set, writing header...")
+
w.WriteHeader(http.StatusOK)
+
+
if flusher, ok := w.(http.Flusher); ok {
+
log.Printf("Flushing headers manually...")
+
flusher.Flush()
+
log.Printf("Headers flushed!")
+
} else {
+
log.Printf("NO FLUSHER!")
+
}
+
+
log.Printf("Calling SSE ServeHTTP...")
+
sseServer.ServeHTTP(w, r)
+
log.Printf("SSE ServeHTTP returned")
+
})
+
+
// Web routes (no Chi)
mux.HandleFunc("/", handleLeaderboard)
mux.HandleFunc("/api/leaderboard", handleAPILeaderboard)
mux.HandleFunc("/api/rating-history/", handleRatingHistory)
mux.HandleFunc("/player/", handlePlayerPage)
-
// Serve static files
+
// Static files
fs := http.FileServer(http.Dir("./static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
-
server := &http.Server{
-
Addr: ":" + webPort,
-
Handler: mux,
-
ReadTimeout: 0, // No timeout for SSE
-
WriteTimeout: 0, // No timeout for SSE
-
MaxHeaderBytes: 1 << 20,
-
}
-
log.Printf("Web server starting on :%s", webPort)
-
if err := server.ListenAndServe(); err != nil {
-
log.Fatal(err)
-
}
+
http.ListenAndServe(":"+webPort, mux)
}
-
func startSSEServer() {
-
// Wrap SSE server with CORS middleware
-
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
w.Header().Set("Access-Control-Allow-Origin", "*")
-
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
-
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
-
-
if r.Method == "OPTIONS" {
-
w.WriteHeader(http.StatusOK)
-
return
-
}
-
-
sseServer.ServeHTTP(w, r)
-
})
-
-
log.Printf("SSE server starting on :%s", ssePort)
-
if err := http.ListenAndServe(":"+ssePort, handler); err != nil {
-
log.Fatal(err)
-
}
-
}
+
var titleStyle = lipgloss.NewStyle().
Bold(true).
+8 -24
sse.go
···
"encoding/json"
"fmt"
"log"
-
"net/http"
"time"
-
"github.com/tmaxmax/go-sse"
+
"github.com/alexandrevicenzi/go-sse"
)
var sseServer *sse.Server
···
}
func initSSE() {
-
sseServer = &sse.Server{}
+
sseServer = sse.NewServer(&sse.Options{
+
Logger: log.New(log.Writer(), "go-sse: ", log.Ldate|log.Ltime),
+
})
}
-
func handleSSE(w http.ResponseWriter, r *http.Request) {
-
sseServer.ServeHTTP(w, r)
-
}
+
// NotifyLeaderboardUpdate sends updated leaderboard to all connected clients
func NotifyLeaderboardUpdate() {
···
return
}
-
msg := &sse.Message{}
-
msg.AppendData(string(data))
-
-
if err := sseServer.Publish(msg); err != nil {
-
log.Printf("SSE: publish failed: %v", err)
-
}
+
sseServer.SendMessage("/events/updates", sse.SimpleMessage(string(data)))
}
func broadcastProgress(player string, currentMatch, totalMatches int, startTime time.Time, queuedPlayers []string) {
···
log.Printf("Broadcasting progress: %s [%d/%d] %.1f%% (queue: %d)", player, currentMatch, totalMatches, percentComplete, len(filteredQueue))
-
msg := &sse.Message{}
-
msg.AppendData(string(data))
-
// Don't set Type - just send as regular message
-
-
if err := sseServer.Publish(msg); err != nil {
-
log.Printf("SSE: progress publish failed: %v", err)
-
}
+
sseServer.SendMessage("/events/updates", sse.SimpleMessage(string(data)))
}
func formatDuration(d time.Duration) string {
···
log.Printf("Broadcasting progress complete")
-
msg := &sse.Message{}
-
msg.AppendData(string(data))
-
// Don't set Type - just send as regular message
-
-
sseServer.Publish(msg)
+
sseServer.SendMessage("/events/updates", sse.SimpleMessage(string(data)))
}
+10 -5
web.go
···
"fmt"
"html/template"
"net/http"
+
+
"github.com/go-chi/chi/v5"
)
const leaderboardHTML = `
···
function connectSSE() {
console.log('Connecting to SSE...');
-
eventSource = new EventSource('http://localhost:8081');
+
eventSource = new EventSource('/events/updates');
eventSource.onopen = () => {
console.log('SSE connection established');
···
};
eventSource.onmessage = (event) => {
+
console.log('SSE raw event:', event);
+
console.log('SSE event.data:', event.data);
try {
const data = JSON.parse(event.data);
console.log('SSE message received:', data);
···
// Leaderboard update
console.log('Updating leaderboard with', data.length, 'entries');
updateLeaderboard(data);
+
} else {
+
console.log('Unknown message type:', data);
}
} catch (error) {
-
console.error('Failed to parse SSE data:', error);
+
console.error('Failed to parse SSE data:', error, 'Raw data:', event.data);
}
};
···
}
func handleRatingHistory(w http.ResponseWriter, r *http.Request) {
-
// Extract username from URL path /api/rating-history/{username}
-
username := r.URL.Path[len("/api/rating-history/"):]
+
username := chi.URLParam(r, "player")
if username == "" {
http.Error(w, "Username required", http.StatusBadRequest)
return
···
}
func handlePlayerPage(w http.ResponseWriter, r *http.Request) {
-
username := r.URL.Path[len("/player/"):]
+
username := chi.URLParam(r, "player")
if username == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return