// Resident hero policy (engine memory): // - After the last WebSocket disconnect, the hero stays in Engine.movements; the world keeps ticking. // - Cold start: ListHeroesForEngineBootstrap loads rows with ws_disconnected_at set (cap 500 default in main). // - Full hero row is saved every offlineDisconnectedFullSaveInterval while heroSubscriber reports false. package game import ( "context" "log/slog" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" ) // BootstrapResidentHeroes loads heroes whose WebSocket session had ended before this process started, // catches up wall time using the same batch path as server-downtime recovery, then registers them // in the engine so movement and combat continue without a live subscriber. func BootstrapResidentHeroes(ctx context.Context, e *Engine, heroStore *storage.HeroStore, sim *OfflineSimulator, limit int, logger *slog.Logger) { if e == nil || heroStore == nil || sim == nil { return } heroes, err := heroStore.ListHeroesForEngineBootstrap(ctx, limit) if err != nil { if logger != nil { logger.Error("engine bootstrap: list heroes", "error", err) } return } now := time.Now() for _, h := range heroes { if h == nil { continue } e.mu.Lock() _, already := e.movements[h.ID] rg := e.roadGraph e.mu.Unlock() if already || rg == nil { continue } e.mergeTownSessionFromRedis(h) if err := sim.SimulateHeroAt(ctx, h, now, true); err != nil { if logger != nil { logger.Error("engine bootstrap: catch-up sim", "hero_id", h.ID, "error", err) } continue } e.mu.Lock() if e.roadGraph == nil { e.mu.Unlock() return } if _, taken := e.movements[h.ID]; taken { e.mu.Unlock() continue } hm := NewHeroMovement(h, e.roadGraph, now) e.movements[h.ID] = hm hm.MarkTownPausePersisted(hm.townPausePersistSignature()) hm.SyncToHero() if hm.State == model.StateFighting { if _, exists := e.combats[h.ID]; !exists { en := PickEnemyForLevel(h.Level) if en.Slug != "" { e.startCombatLocked(hm.Hero, &en) } else { hm.State = model.StateWalking hm.Hero.State = model.StateWalking } } } e.mu.Unlock() if logger != nil { logger.Info("engine bootstrap: resident hero registered", "hero_id", h.ID) } } }