package main import ( "context" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/denisovdennis/autohero/internal/config" "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/handler" "github.com/denisovdennis/autohero/internal/migrate" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/router" "github.com/denisovdennis/autohero/internal/storage" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, })) slog.SetDefault(logger) cfg := config.Load() ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Connect to PostgreSQL. pgPool, err := storage.NewPostgres(ctx, cfg.DB, logger) if err != nil { logger.Error("failed to connect to PostgreSQL", "error", err) os.Exit(1) } defer pgPool.Close() // Run database migrations. if err := migrate.Run(ctx, pgPool, "migrations"); err != nil { logger.Error("database migration failed", "error", err) os.Exit(1) } // Connect to Redis. redisClient, err := storage.NewRedis(ctx, cfg.Redis, logger) if err != nil { logger.Error("failed to connect to Redis", "error", err) os.Exit(1) } defer redisClient.Close() // Combat event channel bridges game engine to WebSocket hub. eventCh := make(chan model.CombatEvent, 256) // Game engine. engine := game.NewEngine(cfg.Game.TickRate, eventCh, logger) // WebSocket hub. hub := handler.NewHub(logger) // Stores (created before hub callbacks which reference them). heroStore := storage.NewHeroStore(pgPool, logger) logStore := storage.NewLogStore(pgPool) // Load road graph for server-authoritative movement. roadGraph, err := game.LoadRoadGraph(ctx, pgPool) if err != nil { logger.Error("failed to load road graph", "error", err) os.Exit(1) } logger.Info("road graph loaded", "towns", len(roadGraph.Towns), "roads", len(roadGraph.Roads), ) // Wire engine dependencies. engine.SetSender(hub) // Hub implements game.MessageSender engine.SetRoadGraph(roadGraph) engine.SetHeroStore(heroStore) // Hub callbacks: on connect, load hero and register movement; on disconnect, persist. hub.OnConnect = func(heroID int64) { hero, err := heroStore.GetByID(ctx, heroID) if err != nil || hero == nil { logger.Error("failed to load hero on ws connect", "hero_id", heroID, "error", err) return } engine.RegisterHeroMovement(hero) } hub.OnDisconnect = func(heroID int64, remainingSameHero int) { engine.HeroSocketDetached(heroID, remainingSameHero == 0) } // Bridge hub incoming client messages to engine's command channel. go func() { for { select { case <-ctx.Done(): return case msg := <-hub.Incoming: engine.IncomingCh() <- game.IncomingMessage{ HeroID: msg.HeroID, Type: msg.Type, Payload: msg.Payload, } } } }() go hub.Run() // Bridge: forward engine events to WebSocket hub. go func() { for { select { case <-ctx.Done(): return case evt := <-eventCh: hub.BroadcastEvent(evt) } } }() // Start game engine. go func() { if err := engine.Run(ctx); err != nil && err != context.Canceled { logger.Error("game engine error", "error", err) } }() // Record server start time for catch-up gap calculation. serverStartedAt := time.Now() offlineSim := game.NewOfflineSimulator(heroStore, logStore, roadGraph, logger) go func() { if err := offlineSim.Run(ctx); err != nil && err != context.Canceled { logger.Error("offline simulator error", "error", err) } }() // HTTP server. r := router.New(router.Deps{ Engine: engine, Hub: hub, PgPool: pgPool, BotToken: cfg.BotToken, AdminBasicAuthUsername: cfg.Admin.BasicAuthUsername, AdminBasicAuthPassword: cfg.Admin.BasicAuthPassword, AdminBasicAuthRealm: cfg.Admin.BasicAuthRealm, Logger: logger, ServerStartedAt: serverStartedAt, }) srv := &http.Server{ Addr: ":" + cfg.ServerPort, Handler: r, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } // Start server in background. go func() { logger.Info("server starting", "port", cfg.ServerPort) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Error("server error", "error", err) os.Exit(1) } }() // Graceful shutdown. quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) sig := <-quit logger.Info("shutting down", "signal", sig.String()) // Cancel game engine and event forwarding. cancel() // Give HTTP connections time to drain. shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("server shutdown error", "error", err) } logger.Info("server stopped") }