You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

563 lines
16 KiB
Go

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 := npc.Type == "merchant"
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
}