package game import ( "errors" "math" "math/rand" "strconv" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) // TownTourOfflineAtNPC resolves a town NPC visit without UI (offline catch-up). type TownTourOfflineAtNPC func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, adventureLog AdventureLogWriter) func scheduleTownTourWanderRetarget(hm *HeroMovement, now time.Time) { cfg := tuning.Get() minMs := cfg.TownTourWanderRetargetMinMs maxMs := cfg.TownTourWanderRetargetMaxMs if minMs <= 0 { minMs = tuning.DefaultValues().TownTourWanderRetargetMinMs } if maxMs <= 0 { maxMs = tuning.DefaultValues().TownTourWanderRetargetMaxMs } hm.Excursion.WanderNextAt = now.Add(randomDurationBetweenMs(minMs, maxMs)) } // beginTownTourExcursion starts attractor-based wandering in the current town (StateInTown). func beginTownTourExcursion(hm *HeroMovement, now time.Time, graph *RoadGraph) { if hm == nil || graph == nil { return } clearLegacyTownNPCState(hm) dur := randomRestDuration() hm.Excursion = model.ExcursionSession{ Kind: model.ExcursionKindTown, Phase: model.ExcursionWild, StartedAt: now, TownTourPhase: string(model.TownTourPhaseWander), TownTourEndsAt: now.Add(dur), } scheduleTownTourWanderRetarget(hm, now) pickTownTourWanderAttractor(hm, graph, now) } func clearLegacyTownNPCState(hm *HeroMovement) { if hm == nil { return } hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownLastNPCLingerUntil = time.Time{} hm.TownNPCWalkTargetID = 0 hm.TownNPCWalkToX = 0 hm.TownNPCWalkToY = 0 hm.TownCenterWalkActive = false hm.TownCenterWalkToX = 0 hm.TownCenterWalkToY = 0 hm.TownPlazaHealActive = false hm.TownLeaveAt = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCKey = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownNPCUILock = false } func clearTownVisitLogFields(hm *HeroMovement) { if hm == nil { return } hm.TownVisitNPCName = "" hm.TownVisitNPCKey = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 } func transitionTownTourToWander(hm *HeroMovement, graph *RoadGraph, now time.Time) { ex := &hm.Excursion ex.TownTourPhase = string(model.TownTourPhaseWander) ex.TownTourNpcID = 0 ex.TownTourStandX = 0 ex.TownTourStandY = 0 ex.TownWelcomeUntil = time.Time{} ex.TownServiceUntil = time.Time{} ex.TownTourDialogOpen = false ex.TownTourInteractionOpen = false clearTownVisitLogFields(hm) scheduleTownTourWanderRetarget(hm, now) pickTownTourWanderAttractor(hm, graph, now) } // pickTownTourWanderAttractor chooses the next wander target: random point in town or stand near an NPC. func pickTownTourWanderAttractor(hm *HeroMovement, graph *RoadGraph, now time.Time) { if hm == nil || graph == nil { return } ex := &hm.Excursion if ex.TownExitPending { return } town := graph.Towns[hm.CurrentTownID] if town == nil { return } cfg := tuning.Get() npcs := graph.TownNPCs[hm.CurrentTownID] pNpc := cfg.TownTourNpcAttractorChance if pNpc <= 0 { pNpc = tuning.DefaultValues().TownTourNpcAttractorChance } if pNpc > 1 { pNpc = 1 } if len(npcs) > 0 && rand.Float64() < pNpc { npc := npcs[rand.Intn(len(npcs))] npcWX, npcWY, posOk := graph.NPCWorldPos(npc.ID, hm.CurrentTownID) if !posOk { npcWX = town.WorldX + npc.OffsetX npcWY = town.WorldY + npc.OffsetY } standoff := cfg.TownNPCStandoffWorld if standoff <= 0 { standoff = tuning.DefaultValues().TownNPCStandoffWorld } toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff) ex.TownTourPhase = string(model.TownTourPhaseNpcApproach) ex.TownTourNpcID = npc.ID ex.TownTourStandX = toX ex.TownTourStandY = toY ex.AttractorSet = false return } // Random point inside town circle (keep margin from edge). cx, cy := town.WorldX, town.WorldY radius := town.Radius if radius < 1 { radius = 8 } margin := radius * 0.12 maxR := radius - margin if maxR < margin { maxR = radius * 0.5 } for attempt := 0; attempt < 24; attempt++ { theta := rand.Float64() * 2 * math.Pi rd := margin + rand.Float64()*math.Max(0.01, maxR-margin) px := cx + math.Cos(theta)*rd py := cy + math.Sin(theta)*rd if graph.HeroInTownAt(px, py) { ex.AttractorX = px ex.AttractorY = py ex.AttractorSet = true ex.TownTourPhase = string(model.TownTourPhaseWander) ex.TownTourNpcID = 0 return } } ex.AttractorX = cx ex.AttractorY = cy ex.AttractorSet = true ex.TownTourPhase = string(model.TownTourPhaseWander) ex.TownTourNpcID = 0 } // AdminTownTourApproachNPC forces npc_approach toward npcID in the hero's current town (admin only). func (hm *HeroMovement) AdminTownTourApproachNPC(graph *RoadGraph, npcID int64, now time.Time) error { if hm == nil || graph == nil { return errors.New("nil movement or graph") } if hm.Excursion.Kind != model.ExcursionKindTown { return errors.New("hero is not on town tour excursion") } if hm.State != model.StateInTown { return errors.New("hero must be in town") } npc, ok := graph.NPCByID[npcID] if !ok { return errors.New("npc not found in world graph") } found := false for _, n := range graph.TownNPCs[hm.CurrentTownID] { if n.ID == npcID { found = true break } } if !found { return errors.New("npc is not in hero's current town") } town := graph.Towns[hm.CurrentTownID] if town == nil { return errors.New("town not found") } cfg := tuning.Get() npcWX, npcWY, posOk := graph.NPCWorldPos(npc.ID, hm.CurrentTownID) if !posOk { npcWX = town.WorldX + npc.OffsetX npcWY = town.WorldY + npc.OffsetY } standoff := cfg.TownNPCStandoffWorld if standoff <= 0 { standoff = tuning.DefaultValues().TownNPCStandoffWorld } ex := &hm.Excursion toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff) ex.TownTourPhase = string(model.TownTourPhaseNpcApproach) ex.TownTourNpcID = npc.ID ex.TownTourStandX = toX ex.TownTourStandY = toY ex.AttractorSet = false ex.TownWelcomeUntil = time.Time{} ex.TownServiceUntil = time.Time{} ex.TownTourDialogOpen = false ex.TownTourInteractionOpen = false hm.TownNPCUILock = false hm.sentTownTourWireSig = "" return nil } // NotifyTownTourClients pushes town_tour_phase, hero_state, and hero_move after an out-of-tick town tour change. func NotifyTownTourClients(sender MessageSender, heroID int64, hm *HeroMovement, graph *RoadGraph, now time.Time) { if sender == nil || hm == nil || graph == nil || hm.Excursion.Kind != model.ExcursionKindTown { return } hm.sentTownTourWireSig = "" sendTownTourUpdate(sender, heroID, hm, graph) h := hm.Hero if h != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) sender.SendToHero(heroID, "hero_state", h) } sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } func townTourWireSig(hm *HeroMovement) string { if hm == nil { return "" } ex := hm.Excursion return ex.TownTourPhase + ":" + strconv.FormatInt(ex.TownTourNpcID, 10) + ":" + strconv.FormatBool(ex.TownExitPending) } func sendTownTourUpdate(sender MessageSender, heroID int64, hm *HeroMovement, graph *RoadGraph) { if sender == nil || hm == nil || graph == nil { return } ex := hm.Excursion town := graph.Towns[hm.CurrentTownID] var townKey string if town != nil { townKey = town.NameKey } var npcID int64 var name, nameKey, npcType string var wx, wy float64 if ex.TownTourNpcID != 0 { if npc, ok := graph.NPCByID[ex.TownTourNpcID]; ok { npcID = npc.ID name = npc.Name nameKey = npc.NameKey npcType = npc.Type if x, y, ok2 := graph.NPCWorldPos(npc.ID, hm.CurrentTownID); ok2 { wx, wy = x, y } else if town != nil { wx = town.WorldX + npc.OffsetX wy = town.WorldY + npc.OffsetY } } } payload := model.TownTourPhasePayload{ Phase: ex.TownTourPhase, TownID: hm.CurrentTownID, TownNameKey: townKey, NpcID: npcID, NpcName: name, NpcNameKey: nameKey, NpcType: npcType, WorldX: wx, WorldY: wy, ExitPending: ex.TownExitPending, } sender.SendToHero(heroID, "town_tour_phase", payload) } func processTownTourMovement( heroID int64, hm *HeroMovement, graph *RoadGraph, now time.Time, sender MessageSender, adventureLog AdventureLogWriter, townTourOffline TownTourOfflineAtNPC, ) { if hm == nil || graph == nil { return } ex := &hm.Excursion cfg := tuning.Get() dt := now.Sub(hm.LastMoveTick).Seconds() if dt <= 0 { dt = movementTickRate().Seconds() } hm.LastMoveTick = now hm.refreshSpeed(now) if !now.Before(ex.TownTourEndsAt) { ex.TownExitPending = true } uiOpen := ex.TownTourDialogOpen || ex.TownTourInteractionOpen if uiOpen && dt > 0 { shift := time.Duration(dt * float64(time.Second)) switch model.TownTourPhase(ex.TownTourPhase) { case model.TownTourPhaseNpcWelcome: if !ex.TownWelcomeUntil.IsZero() { ex.TownWelcomeUntil = ex.TownWelcomeUntil.Add(shift) } case model.TownTourPhaseNpcService: if !ex.TownServiceUntil.IsZero() { ex.TownServiceUntil = ex.TownServiceUntil.Add(shift) } case model.TownTourPhaseRest: if !ex.TownRestUntil.IsZero() { ex.TownRestUntil = ex.TownRestUntil.Add(shift) } ex.TownTourEndsAt = ex.TownTourEndsAt.Add(shift) } } hm.TownNPCUILock = uiOpen walkSpeed := cfg.TownNPCWalkSpeed if walkSpeed <= 0 { walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed } switch model.TownTourPhase(ex.TownTourPhase) { case model.TownTourPhaseWander: if !ex.AttractorSet { // Defensive: pick a wander target. pickTownTourWanderAttractor(hm, graph, now) } arrived := hm.stepTowardAttractor(now, dt) if !arrived { break } // At wander attractor. if ex.TownExitPending { hm.LeaveTown(graph, now) hm.Excursion = model.ExcursionSession{} hm.sentTownTourWireSig = "" if sender != nil { sender.SendToHero(heroID, "town_exit", model.TownExitPayload{}) if route := hm.RoutePayload(); route != nil { sender.SendToHero(heroID, "route_assigned", route) } } return } if now.Before(ex.WanderNextAt) { break } hpFrac := 1.0 if hm.Hero != nil && hm.Hero.MaxHP > 0 { hpFrac = float64(hm.Hero.HP) / float64(hm.Hero.MaxHP) } th := cfg.TownRestHpThreshold if th <= 0 { th = tuning.DefaultValues().TownRestHpThreshold } rch := cfg.TownRestChance if rch <= 0 { rch = tuning.DefaultValues().TownRestChance } if rch > 1 { rch = 1 } if hpFrac < th && rand.Float64() < rch && !ex.TownExitPending { minR := cfg.TownTourRestMinMs maxR := cfg.TownTourRestMaxMs if minR <= 0 { minR = tuning.DefaultValues().TownTourRestMinMs } if maxR <= 0 { maxR = tuning.DefaultValues().TownTourRestMaxMs } ex.TownTourPhase = string(model.TownTourPhaseRest) ex.TownRestUntil = now.Add(randomDurationBetweenMs(minR, maxR)) ex.AttractorSet = false break } scheduleTownTourWanderRetarget(hm, now) pickTownTourWanderAttractor(hm, graph, now) case model.TownTourPhaseNpcApproach: arrived := hm.stepTowardWorldPoint(dt, ex.TownTourStandX, ex.TownTourStandY, walkSpeed) if !arrived { break } npc, ok := graph.NPCByID[ex.TownTourNpcID] if !ok { transitionTownTourToWander(hm, graph, now) break } if sender != nil { // Online: welcome + dialog. ex.TownTourPhase = string(model.TownTourPhaseNpcWelcome) welcomeMs := cfg.TownWelcomeDurationMs if welcomeMs <= 0 { welcomeMs = tuning.DefaultValues().TownWelcomeDurationMs } ex.TownWelcomeUntil = now.Add(time.Duration(welcomeMs) * time.Millisecond) hm.TownVisitNPCName = npc.Name hm.TownVisitNPCKey = npc.NameKey hm.TownVisitNPCType = npc.Type hm.TownVisitStartedAt = now hm.TownVisitLogsEmitted = 0 townNameKey := "" if tt := graph.Towns[hm.CurrentTownID]; tt != nil { townNameKey = tt.NameKey } sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ NPCID: npc.ID, Name: npc.Name, NameKey: npc.NameKey, Type: npc.Type, TownID: hm.CurrentTownID, TownNameKey: townNameKey, WorldX: ex.TownTourStandX, WorldY: ex.TownTourStandY, }) legacyMerchantSell := model.IsGearVendorType(npc.Type) if legacyMerchantSell { share := cfg.MerchantTownAutoSellShare if share <= 0 || share > 1 { share = tuning.DefaultValues().MerchantTownAutoSellShare } soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil) if soldItems > 0 && adventureLog != nil { adventureLog(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseSoldItemsMerchant, Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold}, }, }) } } emitTownNPCVisitLogs(heroID, hm, now, adventureLog) } else { if townTourOffline != nil { townTourOffline(heroID, hm, graph, npc, now, adventureLog) } transitionTownTourToWander(hm, graph, now) } case model.TownTourPhaseNpcWelcome: emitTownNPCVisitLogs(heroID, hm, now, adventureLog) if !ex.TownWelcomeUntil.IsZero() && !now.Before(ex.TownWelcomeUntil) && !ex.TownTourDialogOpen { transitionTownTourToWander(hm, graph, now) break } case model.TownTourPhaseNpcService: emitTownNPCVisitLogs(heroID, hm, now, adventureLog) svcMs := cfg.TownServiceMaxMs if svcMs <= 0 { svcMs = tuning.DefaultValues().TownServiceMaxMs } if !ex.TownServiceUntil.IsZero() && !now.Before(ex.TownServiceUntil) { if sender != nil { sender.SendToHero(heroID, "town_tour_service_end", model.TownTourServiceEndPayload{Reason: "timeout"}) } transitionTownTourToWander(hm, graph, now) break } case model.TownTourPhaseRest: hm.applyTownRestHeal(dt) if !ex.TownRestUntil.IsZero() && now.After(ex.TownRestUntil) { ex.TownTourPhase = string(model.TownTourPhaseWander) ex.TownRestUntil = time.Time{} scheduleTownTourWanderRetarget(hm, now) pickTownTourWanderAttractor(hm, graph, now) } } sig := townTourWireSig(hm) if sender != nil && hm.Excursion.Kind == model.ExcursionKindTown { if sig != hm.sentTownTourWireSig { hm.sentTownTourWireSig = sig sendTownTourUpdate(sender, heroID, hm, graph) } sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.SyncToHero() } // Town tour client command handlers (engine calls under lock). func (hm *HeroMovement) townTourNPCDialogClosed(now time.Time, graph *RoadGraph) { if hm == nil || graph == nil { return } if hm.Excursion.Kind != model.ExcursionKindTown { return } ex := &hm.Excursion ex.TownTourDialogOpen = false hm.TownNPCUILock = ex.TownTourDialogOpen || ex.TownTourInteractionOpen switch model.TownTourPhase(ex.TownTourPhase) { case model.TownTourPhaseNpcWelcome: transitionTownTourToWander(hm, graph, now) case model.TownTourPhaseNpcService: if !ex.TownTourInteractionOpen { transitionTownTourToWander(hm, graph, now) } } } func (hm *HeroMovement) townTourNPCInteractionOpened(now time.Time, graph *RoadGraph) { if hm == nil || graph == nil { return } if hm.Excursion.Kind != model.ExcursionKindTown { return } ex := &hm.Excursion ex.TownTourInteractionOpen = true hm.TownNPCUILock = ex.TownTourDialogOpen || ex.TownTourInteractionOpen if model.TownTourPhase(ex.TownTourPhase) == model.TownTourPhaseNpcWelcome { ex.TownTourPhase = string(model.TownTourPhaseNpcService) svcMs := tuning.Get().TownServiceMaxMs if svcMs <= 0 { svcMs = tuning.DefaultValues().TownServiceMaxMs } ex.TownServiceUntil = now.Add(time.Duration(svcMs) * time.Millisecond) } } func (hm *HeroMovement) townTourNPCInteractionClosed(now time.Time, graph *RoadGraph) { if hm == nil || graph == nil { return } if hm.Excursion.Kind != model.ExcursionKindTown { return } ex := &hm.Excursion ex.TownTourInteractionOpen = false hm.TownNPCUILock = ex.TownTourDialogOpen || ex.TownTourInteractionOpen if model.TownTourPhase(ex.TownTourPhase) == model.TownTourPhaseNpcService { transitionTownTourToWander(hm, graph, now) } } func (hm *HeroMovement) townTourSetDialogOpen(open bool) { if hm == nil || hm.Excursion.Kind != model.ExcursionKindTown { return } hm.Excursion.TownTourDialogOpen = open hm.TownNPCUILock = hm.Excursion.TownTourDialogOpen || hm.Excursion.TownTourInteractionOpen }