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" "github.com/denisovdennis/autohero/internal/tuning" ) 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) digestStore := storage.NewOfflineDigestStore(pgPool) questStore := storage.NewQuestStore(pgPool) gearStore := storage.NewGearStore(pgPool) achievementStore := storage.NewAchievementStore(pgPool) taskStore := storage.NewDailyTaskStore(pgPool) runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool) if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil { logger.Error("failed to load runtime config", "error", err) os.Exit(1) } buffDebuffStore := storage.NewBuffDebuffConfigStore(pgPool) if err := model.ReloadBuffDebuffCatalog(ctx, logger, buffDebuffStore); err != nil { logger.Error("failed to load buff/debuff catalog", "error", err) os.Exit(1) } contentStore := storage.NewContentStore(pgPool) enemiesFromDB, err := contentStore.LoadEnemyTemplates(ctx) if err != nil { logger.Error("failed to load enemy templates from db", "error", err) os.Exit(1) } model.SetEnemyTemplates(enemiesFromDB) gearFamiliesFromDB, err := contentStore.LoadGearFamilies(ctx) if err != nil { logger.Error("failed to load gear templates from db", "error", err) os.Exit(1) } model.SetGearCatalog(gearFamiliesFromDB) // 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) engine.SetTownSessionStore(storage.NewTownSessionStore(redisClient)) engine.SetQuestStore(questStore) engine.SetAdventureLog(func(heroID int64, line model.AdventureLogLine) { logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := logStore.Add(logCtx, heroID, line); err != nil { logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) return } hub.SendToHero(heroID, "adventure_log_line", line) }) engine.SetDigestStore(digestStore) engine.SetHeroSubscriber(hub.IsHeroConnected) // 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) { disconnectAt := time.Now() engine.HeroSocketDetached(heroID, remainingSameHero == 0, disconnectAt) if remainingSameHero == 0 { dctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := heroStore.SetWsDisconnectedAt(dctx, heroID, disconnectAt); err != nil { logger.Warn("set ws_disconnected_at", "hero_id", heroID, "error", err) } } } // Bridge hub incoming client messages to engine's command channel. go func() { for { select { case <-ctx.Done(): return case msg := <-hub.Incoming: if engine.IsTimePaused() { hub.SendToHero(msg.HeroID, "error", model.ErrorPayload{ Code: "time_paused", Message: "server time is paused", }) continue } 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) } } }() // Record server start time for catch-up gap calculation. serverStartedAt := time.Now() bootstrapSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, nil, nil). WithCombatTickRate(engine.TickRate()). WithRewardStores(gearStore, achievementStore, taskStore). WithDigestStore(digestStore) bootCtx, bootCancel := context.WithTimeout(ctx, 3*time.Minute) game.BootstrapResidentHeroes(bootCtx, engine, heroStore, bootstrapSim, logger) bootCancel() // Start game engine (after resident heroes are registered). go func() { if err := engine.Run(ctx); err != nil && err != context.Canceled { logger.Error("game engine error", "error", err) } }() // HTTP server. r := router.New(router.Deps{ Engine: engine, Hub: hub, PgPool: pgPool, BotToken: cfg.BotToken, PaymentProviderToken: cfg.PaymentProviderToken, 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") }