package main import ( "flag" "fmt" "log" "os" "os/signal" "syscall" "time" "thehouseoficarus/internal/admin" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/config" "thehouseoficarus/internal/game" "thehouseoficarus/internal/net" ) func main() { configPath := flag.String("config", "config.yaml", "path to config file") flag.Parse() dataDir := os.Getenv("DATA_DIR") if dataDir == "" { dataDir = "data" } cfg, err := config.Load(*configPath) if err != nil { if os.IsNotExist(err) { log.Printf("config file %s not found, creating with defaults", *configPath) if err := config.SaveDefault(*configPath); err != nil { log.Fatalf("failed to create config: %v", err) } cfg = config.Default() } else { log.Fatalf("failed to load config: %v", err) } } if cfg.GameConstants.ShopDefaultRestock > 0 { behavior.ShopDefaultRestock = cfg.GameConstants.ShopDefaultRestock } g := game.New(dataDir, &cfg.DefaultColors, &cfg.ConstantColors, cfg.StartupValidation, cfg.StartingRoom) // The master per-tick subscriber. Registered as engine subscription ID 1, // the engine fires it before any dynamically-registered combat/aggro/respawn // callbacks (engine.processTick sorts subscribers by registration ID). Its // body — the canonical per-tick system sequence — is enforced and tested in // internal/game/tick_systems.go and documented in // building_guide/tick_order.md. Reordering it is a breaking change. // SubscribeTicks executes the body on the game's owner goroutine so the // whole tick sequence serializes against session input and admin tasks. g.SubscribeTicks(1, func() bool { g.RunTickSystems() return true }) server, err := net.NewServer(cfg) if err != nil { 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 // on the owner and MUST NOT redispatch (that would deadlock). adminRewriteRoomIDs := g.MigrateRoomIDsOnOwner adminRebuildCraftIndex := g.RebuildCraftIndex adminEvictRoom := g.EvictCasinoRoom adminReloadCourses := g.CourseStore.ReloadAll // Both admin transports share one UndoStack over the shared history file. var sharedUndo *admin.UndoStack if cfg.AdminHTTP.Enabled { adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, adminReloadCourses, adminRewriteRoomIDs, adminEvictRoom, adminRebuildCraftIndex, *configPath, g.RunAdminOnOwner) 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) sharedUndo = adminSrv.UndoStack() go func() { if err := adminSrv.ListenAndServe(); err != nil { log.Printf("admin HTTP server error: %v", err) } }() log.Printf("Admin HTTP listening on :%d", cfg.AdminHTTP.Port) } if cfg.AdminHTTPS.Enabled { adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, adminReloadCourses, adminRewriteRoomIDs, adminEvictRoom, adminRebuildCraftIndex, *configPath, g.RunAdminOnOwner) 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) if sharedUndo != nil { adminSrv.SetUndoStack(sharedUndo) } go func() { if err := adminSrv.ListenAndServe(); err != nil { log.Printf("admin HTTPS server error: %v", err) } }() log.Printf("Admin HTTPS listening on :%d", cfg.AdminHTTPS.Port) } if cfg.Telnet.Enabled { log.Printf("Telnet listening on :%d", cfg.Telnet.Port) } if cfg.TelnetTLS.Enabled { log.Printf("Telnet+TLS listening on :%d", cfg.TelnetTLS.Port) } if cfg.HTTP.Enabled { log.Printf("HTTP listening on :%d", cfg.HTTP.Port) } if cfg.HTTPS.Enabled { log.Printf("HTTPS listening on :%d", cfg.HTTPS.Port) } fmt.Println() // blank line after log prefixes sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) // 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; main then reports // a failed final save with a non-zero exit (see below). g.SetTransportStop(server.Shutdown) go func() { <-sigCh // Restore default signal handling after the first signal so a second // SIGINT/SIGTERM force-quits a hung shutdown instead of being // swallowed by this one-shot handler. signal.Stop(sigCh) signal.Reset(syscall.SIGINT, syscall.SIGTERM) g.RequestShutdown("signal") }() if err := server.ListenAndServe(g.HandleSessionOnOwner); err != nil { // A transport failure (for example a fatal Accept error) is not a // clean shutdown: start the full shutdown sequence and WAIT for it, // so online state is saved and outstanding wagers are refunded before // exiting non-zero. RequestShutdown is idempotent, so a failure during // a real shutdown just waits on the already-running sequence. log.Printf("server error: %v", err) g.RequestShutdown("transport failure") select { case <-g.ShutdownDone(): case <-time.After(30 * time.Second): log.Printf("shutdown: timed out waiting for the shutdown sequence to finish") } if serr := g.ShutdownSaveError(); serr != nil { log.Fatalf("shutdown: final save failed: %v", serr) } os.Exit(1) } // 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) } }