From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- cmd/thoi/main.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) (limited to 'cmd') diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go index a07fbcc..cff6eb6 100644 --- a/cmd/thoi/main.go +++ b/cmd/thoi/main.go @@ -42,8 +42,6 @@ func main() { } g := game.New(dataDir, &cfg.DefaultColors, &cfg.ConstantColors, cfg.StartupValidation, cfg.StartingRoom) - g.Ticks.Start(cfg.TickLength) - defer g.Ticks.Stop() // The master per-tick subscriber. Registered as engine subscription ID 1, // the engine fires it before any dynamically-registered combat/aggro/respawn @@ -63,6 +61,12 @@ func main() { log.Fatalf("failed to start server: %v", err) } g.SetHub(server.Hub()) + // Start the tick engine only after the hub exists: every tick system reads + // g.Hub on the owner goroutine, so SetHub must happen-before the first + // tick. Starting earlier left a window (net.NewServer generates its RSA + // keys in between) in which a tick could observe a nil hub. + g.Ticks.Start(cfg.TickLength) + defer g.Ticks.Stop() // The entire editor API request is one owner task, including file commits, // runtime migration, undo history and reloads. These callbacks already run @@ -77,6 +81,9 @@ func main() { if err != nil { log.Fatalf("failed to start admin HTTP server: %v", err) } + // The raw file editor must refuse a file whose character/account is + // online; wired before ListenAndServe so the first request sees it. + adminSrv.SetPlayerFileOnlineCheck(g.AdminPlayerFileOnline) go func() { if err := adminSrv.ListenAndServe(); err != nil { log.Printf("admin HTTP server error: %v", err) @@ -90,6 +97,9 @@ func main() { if err != nil { log.Fatalf("failed to start admin HTTPS server: %v", err) } + // Same online-file refusal as the HTTP editor: both servers expose + // the same raw file API. + adminSrv.SetPlayerFileOnlineCheck(g.AdminPlayerFileOnline) go func() { if err := adminSrv.ListenAndServe(); err != nil { log.Printf("admin HTTPS server error: %v", err) @@ -118,9 +128,8 @@ func main() { // One shutdown request handler: signals and the in-game shutdown // command both converge on RequestShutdown, which drains work, // resolves outstanding wagers, saves owned state, then closes the - // transports. ListenAndServe returns after Shutdown, and main returns - // normally (no os.Exit; a final-save failure is logged and the exit - // code reports it via the transport error path). + // transports. ListenAndServe returns after Shutdown; main then reports + // a failed final save with a non-zero exit (see below). g.SetTransportStop(server.Shutdown) go func() { <-sigCh @@ -130,4 +139,11 @@ func main() { if err := server.ListenAndServe(g.HandleSessionOnOwner); err != nil { log.Fatalf("server error: %v", err) } + // The transports close inside the shutdown sequence, so a clean return + // from ListenAndServe is where the final save is known to have finished. + // It needs its own report: the transport error path can only say that + // the server stopped, never that online state failed to persist. + if err := g.ShutdownSaveError(); err != nil { + log.Fatalf("shutdown: final save failed: %v", err) + } } -- cgit v1.2.3