diff --git a/backend/internal/game/town_tour.go b/backend/internal/game/town_tour.go new file mode 100644 index 0000000..6937109 --- /dev/null +++ b/backend/internal/game/town_tour.go @@ -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 +} diff --git a/backend/internal/game/town_tour_test.go b/backend/internal/game/town_tour_test.go new file mode 100644 index 0000000..1a37a2c --- /dev/null +++ b/backend/internal/game/town_tour_test.go @@ -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) + } +} diff --git a/docs/engine_unified_offline_online.md b/docs/engine_unified_offline_online.md new file mode 100644 index 0000000..a4e5626 --- /dev/null +++ b/docs/engine_unified_offline_online.md @@ -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 handler’s 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. diff --git a/docs/excursion_attractor_fsm.md b/docs/excursion_attractor_fsm.md new file mode 100644 index 0000000..e1a03fc --- /dev/null +++ b/docs/excursion_attractor_fsm.md @@ -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.