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

chore: add the cmd

dunkirk.sh 30ced41a 5ce1ddf5

verified
+179 -1
+1 -1
.gitignore
··· 1 1 .ssh/ 2 2 submissions/ 3 3 *.db 4 - battleship-arena 4 + bin 5 5 *.log 6 6 build/ 7 7
+178
cmd/battleship-arena/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "log" 7 + "net/http" 8 + "os" 9 + "os/signal" 10 + "syscall" 11 + "time" 12 + 13 + tea "github.com/charmbracelet/bubbletea" 14 + "github.com/charmbracelet/lipgloss" 15 + "github.com/charmbracelet/ssh" 16 + "github.com/charmbracelet/wish" 17 + "github.com/charmbracelet/wish/bubbletea" 18 + "github.com/charmbracelet/wish/logging" 19 + "github.com/charmbracelet/wish/scp" 20 + "github.com/go-chi/chi/v5" 21 + "github.com/go-chi/chi/v5/middleware" 22 + 23 + "battleship-arena/internal/runner" 24 + "battleship-arena/internal/server" 25 + "battleship-arena/internal/storage" 26 + "battleship-arena/internal/tui" 27 + ) 28 + 29 + type Config struct { 30 + Host string 31 + SSHPort string 32 + WebPort string 33 + UploadDir string 34 + ResultsDB string 35 + AdminPasscode string 36 + ExternalURL string 37 + } 38 + 39 + func loadConfig() Config { 40 + cfg := Config{ 41 + Host: getEnv("BATTLESHIP_HOST", "0.0.0.0"), 42 + SSHPort: getEnv("BATTLESHIP_SSH_PORT", "2222"), 43 + WebPort: getEnv("BATTLESHIP_WEB_PORT", "8081"), 44 + UploadDir: getEnv("BATTLESHIP_UPLOAD_DIR", "./submissions"), 45 + ResultsDB: getEnv("BATTLESHIP_RESULTS_DB", "./results.db"), 46 + AdminPasscode: getEnv("BATTLESHIP_ADMIN_PASSCODE", "battleship-admin-override"), 47 + ExternalURL: getEnv("BATTLESHIP_EXTERNAL_URL", "http://localhost:8081"), 48 + } 49 + return cfg 50 + } 51 + 52 + func getEnv(key, defaultValue string) string { 53 + if value := os.Getenv(key); value != "" { 54 + return value 55 + } 56 + return defaultValue 57 + } 58 + 59 + func main() { 60 + cfg := loadConfig() 61 + 62 + if err := initStorage(cfg); err != nil { 63 + log.Fatal(err) 64 + } 65 + 66 + server.InitSSE() 67 + server.SetConfig(cfg.AdminPasscode, cfg.ExternalURL) 68 + 69 + workerCtx, workerCancel := context.WithCancel(context.Background()) 70 + defer workerCancel() 71 + go runner.StartWorker(workerCtx, cfg.UploadDir, server.BroadcastProgress, server.NotifyLeaderboardUpdate, server.BroadcastProgressComplete) 72 + 73 + toClient, fromClient := server.NewSCPHandlers(cfg.UploadDir) 74 + sshServer, err := wish.NewServer( 75 + wish.WithAddress(cfg.Host + ":" + cfg.SSHPort), 76 + wish.WithHostKeyPath(".ssh/battleship_arena"), 77 + wish.WithPublicKeyAuth(server.PublicKeyAuthHandler), 78 + wish.WithPasswordAuth(server.PasswordAuthHandler), 79 + wish.WithSubsystem("sftp", server.SFTPHandler(cfg.UploadDir)), 80 + wish.WithMiddleware( 81 + scp.Middleware(toClient, fromClient), 82 + bubbletea.Middleware(teaHandler), 83 + logging.Middleware(), 84 + ), 85 + ) 86 + if err != nil { 87 + log.Fatal(err) 88 + } 89 + 90 + done := make(chan os.Signal, 1) 91 + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 92 + 93 + log.Printf("SSH server listening on %s:%s", cfg.Host, cfg.SSHPort) 94 + log.Printf("Web leaderboard at %s", cfg.ExternalURL) 95 + 96 + go func() { 97 + if err := sshServer.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { 98 + log.Fatal(err) 99 + } 100 + }() 101 + 102 + go func() { 103 + <-done 104 + log.Println("Shutting down servers...") 105 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 106 + defer cancel() 107 + if err := sshServer.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { 108 + log.Fatal(err) 109 + } 110 + os.Exit(0) 111 + }() 112 + 113 + r := chi.NewRouter() 114 + r.Use(middleware.Logger) 115 + r.Use(middleware.Recoverer) 116 + r.Mount("/events/", server.SSEServer) 117 + r.Get("/api/leaderboard", server.HandleAPILeaderboard) 118 + r.Get("/api/rating-history/{player}", server.HandleRatingHistory) 119 + r.Get("/player/{player}", server.HandlePlayerPage) 120 + r.Get("/user/{username}", server.HandleUserProfile) 121 + r.Get("/users", server.HandleUsers) 122 + r.Get("/", server.HandleLeaderboard) 123 + 124 + log.Println("Server running at " + cfg.ExternalURL) 125 + http.ListenAndServe(":"+cfg.WebPort, r) 126 + } 127 + 128 + func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { 129 + if len(s.Command()) > 0 { 130 + return nil, nil 131 + } 132 + 133 + // Check if user needs onboarding 134 + needsOnboarding := false 135 + if val := s.Context().Value("needs_onboarding"); val != nil { 136 + needsOnboarding = val.(bool) 137 + } 138 + 139 + pty, _, active := s.Pty() 140 + if !active { 141 + wish.Fatalln(s, "no active terminal") 142 + return nil, nil 143 + } 144 + 145 + if needsOnboarding { 146 + // Run onboarding first 147 + publicKey := "" 148 + if val := s.Context().Value("public_key"); val != nil { 149 + publicKey = val.(string) 150 + } 151 + 152 + m := tui.NewOnboardingModel(s.User(), publicKey, pty.Window.Width, pty.Window.Height) 153 + return m, []tea.ProgramOption{tea.WithAltScreen()} 154 + } 155 + 156 + m := tui.InitialModel(s.User(), pty.Window.Width, pty.Window.Height) 157 + return m, []tea.ProgramOption{tea.WithAltScreen()} 158 + } 159 + 160 + func initStorage(cfg Config) error { 161 + if err := os.MkdirAll(cfg.UploadDir, 0755); err != nil { 162 + return err 163 + } 164 + 165 + db, err := storage.InitDB(cfg.ResultsDB) 166 + if err != nil { 167 + return err 168 + } 169 + storage.DB = db 170 + 171 + return nil 172 + } 173 + 174 + var titleStyle = lipgloss.NewStyle(). 175 + Bold(true). 176 + Foreground(lipgloss.Color("205")). 177 + MarginTop(1). 178 + MarginBottom(1)