town tour
parent
3d0b050cce
commit
6bbdc217de
@ -0,0 +1,562 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
"github.com/denisovdennis/autohero/internal/tuning"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testGraphTownTour(t *testing.T) *RoadGraph {
|
||||||
|
t.Helper()
|
||||||
|
g := testGraph()
|
||||||
|
g.Towns[1].Radius = 35
|
||||||
|
npc := TownNPC{ID: 101, Name: "Merchant", NameKey: "npc.merchant.test", Type: "merchant", OffsetX: 2, OffsetY: 1}
|
||||||
|
g.TownNPCs[1] = []TownNPC{npc}
|
||||||
|
g.NPCByID[101] = npc
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func heroInTown(id int64, townID int64) *model.Hero {
|
||||||
|
return &model.Hero{
|
||||||
|
ID: id, Level: 5, HP: 900, MaxHP: 1000,
|
||||||
|
Attack: 50, Defense: 30, Speed: 1.0,
|
||||||
|
Strength: 10, Constitution: 10, Agility: 10, Luck: 5,
|
||||||
|
State: model.StateInTown,
|
||||||
|
CurrentTownID: &townID,
|
||||||
|
PositionX: 1, PositionY: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTownTour_WelcomeTimeoutReturnsToWander(t *testing.T) {
|
||||||
|
graph := testGraphTownTour(t)
|
||||||
|
hero := heroInTown(1, 1)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.CurrentTownID = 1
|
||||||
|
hm.CurrentX = 1
|
||||||
|
hm.CurrentY = 1
|
||||||
|
hm.LastMoveTick = now
|
||||||
|
hm.Excursion = model.ExcursionSession{
|
||||||
|
Kind: model.ExcursionKindTown,
|
||||||
|
Phase: model.ExcursionWild,
|
||||||
|
TownTourPhase: string(model.TownTourPhaseNpcWelcome),
|
||||||
|
TownWelcomeUntil: now.Add(-time.Second),
|
||||||
|
TownTourNpcID: 101,
|
||||||
|
TownTourStandX: 3,
|
||||||
|
TownTourStandY: 2,
|
||||||
|
AttractorSet: true,
|
||||||
|
AttractorX: 3,
|
||||||
|
AttractorY: 2,
|
||||||
|
}
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(50*time.Millisecond), nil, nil, nil, nil, nil, nil)
|
||||||
|
if hm.Excursion.TownTourPhase != string(model.TownTourPhaseWander) {
|
||||||
|
t.Fatalf("expected wander after welcome timeout, got %q", hm.Excursion.TownTourPhase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTownTour_DialogClosedFromWelcomeLeavesWelcome(t *testing.T) {
|
||||||
|
graph := testGraphTownTour(t)
|
||||||
|
hero := heroInTown(1, 1)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.CurrentTownID = 1
|
||||||
|
hm.Excursion = model.ExcursionSession{
|
||||||
|
Kind: model.ExcursionKindTown,
|
||||||
|
Phase: model.ExcursionWild,
|
||||||
|
TownTourPhase: string(model.TownTourPhaseNpcWelcome),
|
||||||
|
TownWelcomeUntil: now.Add(time.Hour),
|
||||||
|
TownTourNpcID: 101,
|
||||||
|
}
|
||||||
|
hm.townTourNPCDialogClosed(now, graph)
|
||||||
|
if model.TownTourPhase(hm.Excursion.TownTourPhase) == model.TownTourPhaseNpcWelcome {
|
||||||
|
t.Fatal("still in npc_welcome after dialog closed")
|
||||||
|
}
|
||||||
|
if !hm.Excursion.TownWelcomeUntil.IsZero() {
|
||||||
|
t.Fatal("expected TownWelcomeUntil cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTownTour_DefaultPNpc_AtLeastOneNpcOpportunity uses default tuning to estimate P(≥1 NPC attractor pick)
|
||||||
|
// over a synthetic town stay (retarget cadence vs tour length). Target from design: ≥ 0.6.
|
||||||
|
func TestTownTour_DefaultPNpc_AtLeastOneNpcOpportunity(t *testing.T) {
|
||||||
|
cfg := tuning.DefaultValues()
|
||||||
|
pNpc := cfg.TownTourNpcAttractorChance
|
||||||
|
minRT := cfg.TownTourWanderRetargetMinMs
|
||||||
|
maxRT := cfg.TownTourWanderRetargetMaxMs
|
||||||
|
minStay := cfg.TownRestMinMs
|
||||||
|
maxStay := cfg.TownRestMaxMs
|
||||||
|
if minRT <= 0 || maxRT < minRT || minStay <= 0 || maxStay < minStay {
|
||||||
|
t.Fatal("invalid default town tour / rest durations")
|
||||||
|
}
|
||||||
|
const trials = 8000
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
hits := 0
|
||||||
|
for i := 0; i < trials; i++ {
|
||||||
|
stayMs := minStay + rng.Int63n(maxStay-minStay+1)
|
||||||
|
anyNpc := false
|
||||||
|
for elapsed := int64(0); elapsed < stayMs; {
|
||||||
|
if rng.Float64() < pNpc {
|
||||||
|
anyNpc = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
step := minRT + rng.Int63n(maxRT-minRT+1)
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
elapsed += step
|
||||||
|
}
|
||||||
|
if anyNpc {
|
||||||
|
hits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rate := float64(hits) / float64(trials)
|
||||||
|
if rate < 0.6 {
|
||||||
|
t.Fatalf("Monte Carlo P(≥1 NPC retarget)=%.3f with defaults; want >= 0.6 (townTourNpcAttractorChance=%.3f)", rate, pNpc)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
# Excursion FSM: attractor movement (roadside + adventure)
|
||||||
|
|
||||||
|
This document describes the **server-authoritative** mini-excursion flow: the hero moves in **world coordinates** toward successive **attractors** instead of using a time-based perpendicular offset from the road spine.
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
| Name | `ExcursionPhase` | Meaning |
|
||||||
|
|------|------------------|---------|
|
||||||
|
| First exit into forest | `out` | Walk from the frozen road point to the first forest attractor. |
|
||||||
|
| Wilderness | `wild` | Heal / wander / encounters (depends on `ExcursionKind`). |
|
||||||
|
| Return leg | `return` | Walk back to the road (adventure) or to saved `StartX/Y` (roadside). |
|
||||||
|
|
||||||
|
Product language sometimes calls the return leg “out”; in code it is always **`return`**.
|
||||||
|
|
||||||
|
## Session kinds (`ExcursionKind`)
|
||||||
|
|
||||||
|
| Kind | Trigger | `HeroMovement` state |
|
||||||
|
|------|---------|----------------------|
|
||||||
|
| `roadside` | Low HP on road → `beginRoadsideRest` | `StateResting`, `RestKindRoadside` |
|
||||||
|
| `adventure` | Random roll while walking → `beginExcursion` | `StateWalking` with active `Excursion` |
|
||||||
|
| `town` | In-town tour (separate sub-FSM) | `StateInTown` |
|
||||||
|
|
||||||
|
Roadside and adventure share attractor stepping helpers; town uses its own tour phases (`TownTourPhase` in `model/excursion.go`).
|
||||||
|
|
||||||
|
## Kinematics
|
||||||
|
|
||||||
|
- **`CurrentX` / `CurrentY`** are the true world position during `out` / `wild` / `return`.
|
||||||
|
- Movement uses `stepTowardAttractor` (excursion speed from `refreshSpeed`) or `stepTowardWorldPoint` for town NPC/center walks, with arrival epsilon `ExcursionArrivalEpsilonWorld` (`tuning`).
|
||||||
|
- For attractor-based excursions, `displayOffset` is zero; `hero.position` / `hero_move` match world coords.
|
||||||
|
- **Legacy** JSON blobs without `excursion.kind` but with a non-empty phase are cleared on load (`applyExcursionFromBlob`) so old offset-only sessions are not resumed.
|
||||||
|
|
||||||
|
## Roadside (`roadside`)
|
||||||
|
|
||||||
|
1. **Start:** `StartX/Y` = road position; road progress frozen (`RoadFreezeWaypoint` / `RoadFreezeFraction`); first forest attractor from `pickExcursionForestAttractor` (depth from tuning).
|
||||||
|
2. **`out`:** Step toward attractor until within epsilon → **`wild`**.
|
||||||
|
3. **`wild`:** Regen `RoadsideRestHpPerS`; cap by random duration `[RoadsideRestMinMs, RoadsideRestMaxMs]` or early exit when `HP/MaxHP ≥ RoadsideRestExitHp` (default **0.85**).
|
||||||
|
4. **`return`:** Attractor = `StartX/Y`; on arrival → `endExcursion`, restore road progress, clear rest.
|
||||||
|
|
||||||
|
Persisted under `heroes.town_pause` → `excursion` (`ExcursionPersisted`).
|
||||||
|
|
||||||
|
## Adventure (`adventure`)
|
||||||
|
|
||||||
|
1. **Start:** `StartX/Y` on road; `AdventureEndsAt = now + uniform[AdventureDurationMinMs, AdventureDurationMaxMs]`; first `out` attractor like roadside (depth `AdventureDepthWorldUnits`).
|
||||||
|
2. **`out`:** Reach attractor → **`wild`**, schedule wander (`WanderNextAt`, `adventurePickWanderAttractor` within `AdventureWanderRadius`).
|
||||||
|
3. **`wild`:** While `now < AdventureEndsAt`: step toward current attractor; retarget on `WanderNextAt`; roll encounters; if `HP/MaxHP < LowHpThreshold` → `beginAdventureInlineRest` until `≥ AdventureRestTargetHp` (default **0.85**), then back to `wild`.
|
||||||
|
4. **Timer elapsed:** `tryBeginAdventureReturn`: if fighting, set `PendingReturnAfterCombat`; else `enterAdventureReturnToRoad` (attractor = closest point on **frozen** road polyline).
|
||||||
|
5. **After combat win:** `ResumeWalking` then `TryAdventureReturnAfterCombat(now)` — also handles timer elapsed while movement ticks were skipped during combat (checks `AdventureEndsAt`, not only the pending flag).
|
||||||
|
6. **`return`:** On arrival at road attractor → `endExcursion`, `excursion_end` WS when applicable.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
- `TownPausePersisted.Excursion` holds kind, phase, freeze snapshot, `startX/Y`, attractor, `adventureEndsAt`, `wanderNextAt`, `pendingReturnAfterCombat`, etc.
|
||||||
|
- Engine persists when `TownPausePersistDue` signature changes (see `townPausePersistSignature` in `movement.go`).
|
||||||
|
|
||||||
|
## WebSocket (online)
|
||||||
|
|
||||||
|
Typical messages: `excursion_start`, `excursion_phase`, `hero_move`, `hero_state`, `excursion_end`. After admin “force return” on adventure, engine sends `excursion_phase` + `hero_move` (not immediate `excursion_end`).
|
||||||
|
|
||||||
|
## Tuning keys (reference)
|
||||||
|
|
||||||
|
| Key | Role |
|
||||||
|
|-----|------|
|
||||||
|
| `AdventureDurationMinMs` / `MaxMs` | Adventure `wild` window |
|
||||||
|
| `AdventureWanderRadius` | Random retarget radius around hero |
|
||||||
|
| `AdventureWanderRetargetMinMs` / `MaxMs` | Retarget interval |
|
||||||
|
| `ExcursionArrivalEpsilonWorld` | Arrival threshold (shared with town step-to-point) |
|
||||||
|
| `RoadsideRestExitHp` | Early end of roadside `wild` |
|
||||||
|
| `AdventureRestTargetHp` | End of adventure inline heal |
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
- `excursionPhase` and `excursionKind` (`roadside` \| `adventure`) on hero JSON; visuals follow `hero_move` world coordinates.
|
||||||
|
|
||||||
|
## Primary source files
|
||||||
|
|
||||||
|
| Area | File |
|
||||||
|
|------|------|
|
||||||
|
| Session model + persist DTO | `backend/internal/model/excursion.go` |
|
||||||
|
| FSM, stepping, persist blob | `backend/internal/game/movement.go` |
|
||||||
|
| Post-combat return | `backend/internal/game/engine.go`, `offline.go` |
|
||||||
|
| Defaults | `backend/internal/tuning/runtime.go` |
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [engine_unified_offline_online.md](./engine_unified_offline_online.md) — single simulation path.
|
||||||
|
- [spec-server-authoritative.md](./spec-server-authoritative.md) — WS envelopes and authority boundaries.
|
||||||
Loading…
Reference in New Issue