v0.11.0 harden persistence and prepare game replays

This commit is contained in:
2026-09-07 14:39:00 -04:00
parent 21dea47694
commit 5d2be4abb2
42 changed files with 3591 additions and 693 deletions
+2 -2
View File
@@ -40,7 +40,8 @@ func runClient() (restart bool) {
display.Println(display.Cyan, "Chess Debug Client")
display.Println(display.Cyan, "API: %s", s.APIBaseURL)
fmt.Println("Type 'help' for commands\n")
fmt.Println("Type 'help' for commands")
fmt.Println()
registry := command.NewRegistry(s)
@@ -133,4 +134,3 @@ func buildPrompt(s *session.Session) string {
return display.Prompt(b.String())
}
+38 -9
View File
@@ -1,6 +1,8 @@
package cli
import (
"database/sql"
"errors"
"flag"
"fmt"
"os"
@@ -122,17 +124,27 @@ func runQuery(args []string) error {
// Print results in tabular format
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Game ID\tWhite Player\tBlack Player\tStart Time")
fmt.Fprintln(w, strings.Repeat("-", 80))
fmt.Fprintln(w, "Game ID\tWhite Player\tBlack Player\tResult\tStarted\tEnded")
fmt.Fprintln(w, strings.Repeat("-", 130))
for _, g := range games {
whiteInfo := fmt.Sprintf("%s (T%d)", g.WhitePlayerID[:8], g.WhiteType)
blackInfo := fmt.Sprintf("%s (T%d)", g.BlackPlayerID[:8], g.BlackType)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
g.GameID[:8]+"...",
whiteInfo := formatStoredPlayer(g.WhitePlayerID, g.WhiteClaimedBy, g.WhiteType)
blackInfo := formatStoredPlayer(g.BlackPlayerID, g.BlackClaimedBy, g.BlackType)
result := g.Result
if result == "" {
result = "ongoing"
}
ended := "-"
if g.EndTimeUTC != nil {
ended = g.EndTimeUTC.Format("2006-01-02 15:04:05")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
abbreviateID(g.GameID),
whiteInfo,
blackInfo,
result,
g.StartTimeUTC.Format("2006-01-02 15:04:05"),
ended,
)
}
w.Flush()
@@ -141,6 +153,21 @@ func runQuery(args []string) error {
return nil
}
func formatStoredPlayer(playerID, claimedBy string, playerType int) string {
value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType)
if claimedBy != "" && claimedBy != playerID {
value += " claim:" + abbreviateID(claimedBy)
}
return value
}
func abbreviateID(value string) string {
if len(value) <= 8 {
return value
}
return value[:8] + "..."
}
func runUser(subcommand string, args []string) error {
switch subcommand {
case "add":
@@ -235,9 +262,11 @@ func runUserAdd(args []string) error {
var userID string
for attempts := 0; attempts < 10; attempts++ {
userID = uuid.New().String()
if _, err := store.GetUserByID(userID); err != nil {
if _, err := store.GetUserByID(userID); errors.Is(err, sql.ErrNoRows) {
// User doesn't exist, ID is unique
break
} else if err != nil {
return fmt.Errorf("failed to check generated user ID: %w", err)
}
if attempts == 9 {
return fmt.Errorf("failed to generate unique user ID after 10 attempts")
@@ -562,7 +591,7 @@ func runUserList(args []string) error {
expires = u.ExpiresAt.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
u.UserID[:8]+"...",
abbreviateID(u.UserID),
u.Username,
u.AccountType,
email,
@@ -575,4 +604,4 @@ func runUserList(args []string) error {
fmt.Printf("\nTotal users: %d\n", len(users))
return nil
}
}
+43 -16
View File
@@ -8,6 +8,7 @@ import (
"flag"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
@@ -43,14 +44,35 @@ func main() {
storagePath = flag.String("storage-path", "", "Path to SQLite database file (disables persistence if empty)")
pidPath = flag.String("pid", "", "Optional path to write PID file")
pidLock = flag.Bool("pid-lock", false, "Lock PID file to allow only one instance (requires -pid)")
logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, or error")
logHTTP = flag.Bool("log-http", true, "Log HTTP requests")
finishedTTL = flag.Duration("finished-game-ttl", service.FinishedGameTTL, "How long completed games remain in memory (0 disables eviction)")
// Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server")
webHost = flag.String("web-host", "localhost", "Web UI server host")
webPort = flag.Int("web-port", 9090, "Web UI server port")
serve = flag.Bool("serve", false, "Enable web UI server")
webHost = flag.String("web-host", "localhost", "Web UI server host")
webPort = flag.Int("web-port", 9090, "Web UI server port")
webAPIURL = flag.String("web-api-url", "", "Browser-visible API base URL (defaults to the API listen address)")
)
flag.Parse()
var level slog.Level
if err := level.UnmarshalText([]byte(*logLevel)); err != nil {
log.Fatalf("Invalid -log-level %q: %v", *logLevel, err)
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey {
attr.Value = slog.TimeValue(attr.Value.Time().UTC())
}
return attr
},
})))
// slog.SetDefault bridges the standard logger through the structured
// handler. Avoid embedding a second timestamp inside its message.
log.SetFlags(0)
// Validate PID flags
if *pidLock && *pidPath == "" {
log.Fatal("Error: -pid-lock flag requires the -pid flag to be set")
@@ -78,11 +100,6 @@ func main() {
if err := store.InitDB(); err != nil {
log.Fatalf("Failed to initialize schema: %v", err)
}
defer func() {
if err := store.Close(); err != nil {
log.Printf("Warning: failed to close storage cleanly: %v", err)
}
}()
} else {
log.Printf("Persistent storage disabled (use -storage-path to enable)")
}
@@ -104,20 +121,27 @@ func main() {
// 2. Initialize the Service with optional storage and auth
svc := service.New(store, jwtSecret)
svc.SetFinishedGameTTL(*finishedTTL)
// Start cleanup job for expired users/sessions
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
go svc.RunCleanupJob(cleanupCtx, service.CleanupJobInterval)
cleanupDone := make(chan struct{})
go func() {
defer close(cleanupDone)
svc.RunCleanupJob(cleanupCtx, service.CleanupJobInterval)
}()
// 3. Initialize the Processor (Orchestrator), injecting the service
proc, err := processor.New(svc)
if err != nil {
cleanupCancel()
<-cleanupDone
svc.Shutdown(gracefulShutdownTimeout)
log.Fatalf("Failed to initialize processor: %v", err)
}
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
app := http.NewFiberApp(proc, svc, *dev)
app := http.NewFiberApp(proc, svc, *dev, *logHTTP)
// API Server configuration
apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort)
@@ -151,13 +175,16 @@ func main() {
if *serve {
webAddr := fmt.Sprintf("%s:%d", *webHost, *webPort)
apiURL := fmt.Sprintf("http://%s", apiAddr)
if *webAPIURL != "" {
apiURL = *webAPIURL
}
go func() {
log.Printf("Web UI Server starting...")
log.Printf("Web UI Listening on: http://%s", webAddr)
log.Printf("Web UI API target: %s", apiURL)
if err := webserver.Start(*webHost, *webPort, apiURL); err != nil {
if err := webserver.Start(*webHost, *webPort, apiURL, *logHTTP); err != nil {
log.Printf("Web UI server error: %v", err)
}
}()
@@ -179,18 +206,18 @@ func main() {
log.Printf("Server forced to shutdown: %v", err)
}
// Close processor after service shutdown
cleanupCancel() // Stop cleanup before closing processor and storage.
<-cleanupDone
// Close processor before the service so engine callbacks have settled.
if err = proc.Close(); err != nil {
log.Printf("Processor close error: %v", err)
}
cleanupCancel() // Stop cleanup job
// Shutdown service first (includes wait registry cleanup)
// Shutdown service (wait registry, accepted storage writes, database).
if err = svc.Shutdown(gracefulShutdownTimeout); err != nil {
log.Printf("Service shutdown error: %v", err)
}
log.Println("Servers exited")
}