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