town tour

master
Denis Ranneft 1 month ago
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,58 @@
# Unified Engine: One Simulation Path for Online and Offline
This document describes how AutoHero runs **all** gameplay logic (movement cadence, encounters, combat, rewards) in a **single** place: the Go `Engine` (`backend/internal/game/engine.go`). WebSocket is observation and command input only; there is no separate “offline world” loop that advances combat differently while the player is away.
## Authority
| Layer | Role |
|--------|------|
| **Engine** | `processMovementTick`, `processCombatTick`, `startCombatLocked`, `HeroMovement` FSM, persistence hooks |
| **WebSocket Hub** | Delivers envelopes only when the hero has at least one connected client (`SendToHero` no-op otherwise) |
| **PostgreSQL** | Durable hero row; periodic and event-driven saves from the engine |
| **Offline digest** | Aggregated summary for “while you were away” UI; filled only after disconnect grace (see below) |
## Resident heroes
- After the **last** WebSocket disconnect for a hero, `HeroSocketDetached` **does not** remove them from `e.movements` or clear combat. The hero keeps ticking like an online session without a viewer.
- In-memory `Hero.WsDisconnectedAt` is set on disconnect (aligned with `heroes.ws_disconnected_at` in the DB) for digest timing.
- **Cold start:** `ListHeroesForEngineBootstrap` (`backend/internal/storage/hero_store.go`) selects heroes with `ws_disconnected_at IS NOT NULL` and a simulatable `state`. `BootstrapResidentHeroes` (`backend/internal/game/engine_bootstrap.go`) runs a **one-shot** wall-time catch-up via `OfflineSimulator.SimulateHeroAt`, then registers the hero in the engine. Live play after that uses only engine combat.
- **Periodic save without WS:** heroes with no subscriber get a full `heroStore.Save` every `offlineDisconnectedFullSaveInterval` (30s) from the movement tick path (`backend/internal/game/engine.go`).
## Combat and encounters
- **Live progression:** encounters call `startCombatLocked`; resolution uses `e.combats` and `processCombatTick` (same for subscribed and unsubscribed heroes).
- **Batch-only paths** (no second “live” world): `SimulateOneFight` / `simulateHeroTick` remain for **bootstrap after restart** and for **server-downtime gap** recovery when the hero is **not** resident in the engine (`catchUpOfflineGap` in `backend/internal/handler/game.go`). If `HeroHasActiveMovement`, gap catch-up **skips** `SimulateHeroAt` so combat is not simulated twice.
## REST and engine consistency
- `Engine.MergeResidentHeroState` copies the authoritative in-engine hero (after `SyncToHero`) into the handlers hero struct.
- **`GET /api/v1/hero/init`** and **`GET /api/v1/hero`**: if the hero is resident, merge from engine and persist so the client and DB match the single simulation.
## Offline digest
- Helpers: `OfflineDigestGrace`, `OfflineDigestCollecting` (`backend/internal/game/offline.go`).
- The engine applies digest deltas on kill, death (including DoT death path), and auto-revive **only when** `OfflineDigestCollecting(hero.WsDisconnectedAt, now)` is true.
- Batch `simulateHeroTick` uses the same rule when a digest store is wired.
## Key source files
| Area | File |
|------|------|
| Engine loop, combat, movement, digest hooks, auto-revive, disconnected save | `backend/internal/game/engine.go` |
| Bootstrap query | `backend/internal/storage/hero_store.go` (`ListHeroesForEngineBootstrap`) |
| Bootstrap orchestration | `backend/internal/game/engine_bootstrap.go` |
| Batch catch-up + digest helpers | `backend/internal/game/offline.go` |
| Hub send if connected | `backend/internal/handler/ws.go` |
| Init / GetHero merge; gap catch-up guard | `backend/internal/handler/game.go` |
| Wiring, bootstrap before `Engine.Run` | `backend/cmd/server/main.go` |
## Scaling notes
- Bootstrap is capped (e.g. 500 heroes in `main`); not every account is loaded into RAM.
- Long-term, explicit unload policy (TTL + final save) can reduce residency memory without reintroducing a second gameplay simulator.
## Related docs
- [spec-server-authoritative.md](./spec-server-authoritative.md) — WS contract and phases.
- [excursion_attractor_fsm.md](./excursion_attractor_fsm.md) — roadside/adventure attractor excursion FSM and persistence.
- [blueprint_server_authoritative.md](./blueprint_server_authoritative.md) — historical gap analysis and migration context.

@ -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…
Cancel
Save