diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5423d17..16e6bac 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -64,6 +64,7 @@ func main() { // Stores (created before hub callbacks which reference them). heroStore := storage.NewHeroStore(pgPool, logger) logStore := storage.NewLogStore(pgPool) + questStore := storage.NewQuestStore(pgPool) runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool) if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil { logger.Error("failed to load runtime config", "error", err) @@ -172,7 +173,7 @@ func main() { // Record server start time for catch-up gap calculation. serverStartedAt := time.Now() - offlineSim := game.NewOfflineSimulator(heroStore, logStore, roadGraph, logger, func() bool { + offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool { return engine.IsTimePaused() }, engine.HeroHasActiveMovement) go func() { diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 3ee4a6b..643476f 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -995,7 +995,7 @@ func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) { routeAssigned := false if e.roadGraph != nil && hm.State == model.StateWalking && hm.Road == nil { hm.pickDestination(e.roadGraph) - hm.assignRoad(e.roadGraph) + hm.assignRoad(e.roadGraph, false) routeAssigned = true } @@ -1070,7 +1070,7 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) { routeAssigned := false if hm.State == model.StateWalking && hm.Road == nil && e.roadGraph != nil { hm.pickDestination(e.roadGraph) - hm.assignRoad(e.roadGraph) + hm.assignRoad(e.roadGraph, false) routeAssigned = true } @@ -1349,7 +1349,7 @@ func (e *Engine) processMovementTick(now time.Time) { } for heroID, hm := range e.movements { - ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter) + ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil) if e.heroStore == nil || hm == nil || hm.Hero == nil { continue } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 113cf68..c1d96b7 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -79,8 +79,18 @@ type HeroMovement struct { TownNPCWalkStart time.Time // when walk began TownNPCWalkArrive time.Time // when hero reaches NPC - // TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause). + // TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end). TownLeaveAt time.Time + // TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded). + TownPlazaHealActive bool + + // TownCenterWalk*: walk from last NPC stand back to town center before road snap (avoids teleport to road spine). + TownCenterWalkArrive time.Time + TownCenterWalkStart time.Time + TownCenterWalkFromX float64 + TownCenterWalkFromY float64 + TownCenterWalkToX float64 + TownCenterWalkToY float64 // Accumulates fractional town-rest regen between ticks. TownRestHealRemainder float64 @@ -188,7 +198,7 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov if hm.DestinationTownID == 0 { hm.pickDestination(graph) } - hm.assignRoad(graph) + hm.assignRoad(graph, false) if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 { hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint hm.WaypointFraction = hm.Excursion.RoadFreezeFraction @@ -206,10 +216,10 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov if hm.DestinationTownID == 0 { hm.pickDestination(graph) } - hm.assignRoad(graph) + hm.assignRoad(graph, false) if hm.Road == nil { hm.pickDestination(graph) - hm.assignRoad(graph) + hm.assignRoad(graph, false) } hm.State = model.StateWalking @@ -361,7 +371,9 @@ func (hm *HeroMovement) pickDestination(graph *RoadGraph) { // assignRoad finds and configures the road from CurrentTownID to DestinationTownID. // If no road exists (hero is mid-road), it finds the nearest town and routes from there. -func (hm *HeroMovement) assignRoad(graph *RoadGraph) { +// startAtFirstWaypoint: place hero at jittered waypoint 0 (departure town) without nearest-point snap — +// use when leaving town so an off-road NPC position does not snap to an arbitrary polyline point. +func (hm *HeroMovement) assignRoad(graph *RoadGraph, startAtFirstWaypoint bool) { road := graph.FindRoad(hm.CurrentTownID, hm.DestinationTownID) if road == nil { // Try finding a road from any nearby town. @@ -409,6 +421,12 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) { hm.WaypointIndex = 0 hm.WaypointFraction = 0 hm.spawnAtRoadStart = false + } else if startAtFirstWaypoint { + wp0 := jitteredRoad.Waypoints[0] + hm.CurrentX = wp0.X + hm.CurrentY = wp0.Y + hm.WaypointIndex = 0 + hm.WaypointFraction = 0 } else { // Restore progress along this hero's jittered polyline from saved world position. hm.snapProgressToNearestPointOnRoad() @@ -479,6 +497,8 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) { hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt) hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt) hm.TownLeaveAt = shift(hm.TownLeaveAt) + hm.TownCenterWalkStart = shift(hm.TownCenterWalkStart) + hm.TownCenterWalkArrive = shift(hm.TownCenterWalkArrive) hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt) hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil) @@ -726,6 +746,9 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { hm.TownVisitLogsEmitted = 0 hm.TownRestHealRemainder = 0 hm.clearNPCWalk() + hm.clearTownCenterWalk() + hm.TownPlazaHealActive = false + hm.TownLeaveAt = time.Time{} if graph != nil && hm.CurrentTownID == 0 { hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) } @@ -858,6 +881,8 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.clearNPCWalk() + hm.clearTownCenterWalk() + hm.TownPlazaHealActive = false ids := graph.TownNPCIDs(destID) if len(ids) == 0 { @@ -892,12 +917,15 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { hm.RestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.clearNPCWalk() + hm.clearTownCenterWalk() + hm.TownPlazaHealActive = false hm.State = model.StateWalking hm.Hero.State = model.StateWalking // Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick. hm.LastMoveTick = now hm.pickDestination(graph) - hm.assignRoad(graph) + // Start exactly at the road origin (current town); snap from an NPC-tile would jump to the wrong spine point. + hm.assignRoad(graph, true) hm.refreshSpeed(now) } @@ -947,6 +975,15 @@ func (hm *HeroMovement) clearNPCWalk() { hm.TownNPCWalkArrive = time.Time{} } +func (hm *HeroMovement) clearTownCenterWalk() { + hm.TownCenterWalkArrive = time.Time{} + hm.TownCenterWalkStart = time.Time{} + hm.TownCenterWalkFromX = 0 + hm.TownCenterWalkFromY = 0 + hm.TownCenterWalkToX = 0 + hm.TownCenterWalkToY = 0 +} + // StartFighting pauses movement for combat. func (hm *HeroMovement) StartFighting() { hm.State = model.StateFighting @@ -1096,6 +1133,21 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { t := hm.TownVisitStartedAt p.TownVisitStartedAt = &t } + if hm.TownPlazaHealActive { + p.TownPlazaHealActive = true + } + p.CenterWalkFromX = hm.TownCenterWalkFromX + p.CenterWalkFromY = hm.TownCenterWalkFromY + p.CenterWalkToX = hm.TownCenterWalkToX + p.CenterWalkToY = hm.TownCenterWalkToY + if !hm.TownCenterWalkStart.IsZero() { + t := hm.TownCenterWalkStart + p.CenterWalkStart = &t + } + if !hm.TownCenterWalkArrive.IsZero() { + t := hm.TownCenterWalkArrive + p.CenterWalkArrive = &t + } } // Persist active excursion session regardless of hero state (the hero can be fighting @@ -1183,6 +1235,17 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) if blob.NPCWalkArrive != nil { hm.TownNPCWalkArrive = *blob.NPCWalkArrive } + hm.TownPlazaHealActive = blob.TownPlazaHealActive + hm.TownCenterWalkFromX = blob.CenterWalkFromX + hm.TownCenterWalkFromY = blob.CenterWalkFromY + hm.TownCenterWalkToX = blob.CenterWalkToX + hm.TownCenterWalkToY = blob.CenterWalkToY + if blob.CenterWalkStart != nil { + hm.TownCenterWalkStart = *blob.CenterWalkStart + } + if blob.CenterWalkArrive != nil { + hm.TownCenterWalkArrive = *blob.CenterWalkArrive + } } // Restore excursion session from blob (may exist alongside any hero state). @@ -1275,6 +1338,10 @@ type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64) // AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB). type AfterTownEnterPersist func(hero *model.Hero) +// TownNPCOfflineInteractHook runs when the hero reaches a town NPC with no WS client (offline catch-up). +// Returns true if the hero stops and interacts (narration + timed logs); false if they walk past without stopping. +type TownNPCOfflineInteractHook func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, adventureLog AdventureLogWriter) bool + func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { if log == nil || hm.TownVisitStartedAt.IsZero() { return @@ -1606,6 +1673,7 @@ func randomDurationBetweenMs(minMs, maxMs int64) time.Duration { // onEncounter is required for walking encounter rolls; if nil, encounters are not triggered. // adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block). // persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town. +// townNPCOfflineInteract, when sender is nil, decides offline buy/heal/quest vs walking past; nil uses legacy auto-sell-only behavior. func ProcessSingleHeroMovementTick( heroID int64, hm *HeroMovement, @@ -1616,6 +1684,7 @@ func ProcessSingleHeroMovementTick( onMerchantEncounter MerchantEncounterHook, adventureLog AdventureLogWriter, persistAfterTownEnter AfterTownEnterPersist, + townNPCOfflineInteract TownNPCOfflineInteractHook, ) { if graph == nil { return @@ -1722,8 +1791,56 @@ func ProcessSingleHeroMovementTick( case model.StateInTown: cfg := tuning.Get() + dtTown := now.Sub(hm.LastMoveTick).Seconds() + if dtTown <= 0 { + dtTown = movementTickRate().Seconds() + } hm.LastMoveTick = now + // --- Walk back to town center after last NPC (avoids road-snap teleport) --- + if !hm.TownCenterWalkArrive.IsZero() { + if !now.Before(hm.TownCenterWalkArrive) { + hm.CurrentX = hm.TownCenterWalkToX + hm.CurrentY = hm.TownCenterWalkToY + hm.clearTownCenterWalk() + if sender != nil { + sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ + X: hm.CurrentX, Y: hm.CurrentY, + TargetX: hm.CurrentX, TargetY: hm.CurrentY, + Speed: 0, Heading: 0, + }) + } + } else { + totalMs := hm.TownCenterWalkArrive.Sub(hm.TownCenterWalkStart).Milliseconds() + if totalMs <= 0 { + totalMs = 1 + } + elapsed := now.Sub(hm.TownCenterWalkStart).Milliseconds() + t := float64(elapsed) / float64(totalMs) + if t > 1 { + t = 1 + } + hm.CurrentX = hm.TownCenterWalkFromX + (hm.TownCenterWalkToX-hm.TownCenterWalkFromX)*t + hm.CurrentY = hm.TownCenterWalkFromY + (hm.TownCenterWalkToY-hm.TownCenterWalkFromY)*t + if sender != nil { + walkSpeed := cfg.TownNPCWalkSpeed + if walkSpeed <= 0 { + walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed + } + dx := hm.TownCenterWalkToX - hm.CurrentX + dy := hm.TownCenterWalkToY - hm.CurrentY + heading := math.Atan2(dy, dx) + sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ + X: hm.CurrentX, Y: hm.CurrentY, + TargetX: hm.TownCenterWalkToX, TargetY: hm.TownCenterWalkToY, + Speed: walkSpeed, Heading: heading, + }) + } + } + hm.SyncToHero() + return + } + // --- Sub-state: hero is walking toward an NPC inside the town --- if hm.TownNPCWalkTargetID != 0 { if !now.Before(hm.TownNPCWalkArrive) { @@ -1736,36 +1853,51 @@ func ProcessSingleHeroMovementTick( hm.clearNPCWalk() if npc, ok := graph.NPCByID[npcID]; ok { + fullVisit := false if sender != nil { sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, WorldX: standX, WorldY: standY, }) - // Snap client interpolation to the NPC tile (visit message alone left the - // hero short of the last hero_move segment). sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ X: hm.CurrentX, Y: hm.CurrentY, TargetX: hm.CurrentX, TargetY: hm.CurrentY, Speed: 0, Heading: 0, }) + fullVisit = true + } else if townNPCOfflineInteract != nil { + fullVisit = townNPCOfflineInteract(heroID, hm, graph, npc, now, adventureLog) + } else { + fullVisit = true } - hm.TownVisitNPCName = npc.Name - hm.TownVisitNPCType = npc.Type - hm.TownVisitStartedAt = now - hm.TownVisitLogsEmitted = 0 - if npc.Type == "merchant" { - share := cfg.MerchantTownAutoSellShare - if share <= 0 || share > 1 { - share = tuning.DefaultValues().MerchantTownAutoSellShare + + if fullVisit { + hm.TownVisitNPCName = npc.Name + hm.TownVisitNPCType = npc.Type + hm.TownVisitStartedAt = now + hm.TownVisitLogsEmitted = 0 + legacyMerchantSell := npc.Type == "merchant" && (sender != nil || townNPCOfflineInteract == nil) + 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, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold)) + } } - soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil) - if soldItems > 0 && adventureLog != nil { - adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold)) + emitTownNPCVisitLogs(heroID, hm, now, adventureLog) + hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) + } else { + if adventureLog != nil { + adventureLog(heroID, fmt.Sprintf("You notice %s but decide not to stop.", npc.Name)) } + hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) } - emitTownNPCVisitLogs(heroID, hm, now, adventureLog) + } else { + hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) } - hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) } else { // Still walking — interpolate position. totalMs := hm.TownNPCWalkArrive.Sub(hm.TownNPCWalkStart).Milliseconds() @@ -1808,14 +1940,80 @@ func ProcessSingleHeroMovementTick( emitTownNPCVisitLogs(heroID, hm, now, adventureLog) if len(hm.TownNPCQueue) == 0 && hm.TownNPCWalkTargetID == 0 { + town := graph.Towns[hm.CurrentTownID] + if town == nil { + hm.LeaveTown(graph, now) + hm.SyncToHero() + if sender != nil { + sender.SendToHero(heroID, "town_exit", model.TownExitPayload{}) + if route := hm.RoutePayload(); route != nil { + sender.SendToHero(heroID, "route_assigned", route) + } + } + return + } + cx, cy := town.WorldX, town.WorldY + const plazaEps = 0.55 + dPlaza := math.Hypot(hm.CurrentX-cx, hm.CurrentY-cy) + if dPlaza > plazaEps { + dx := cx - hm.CurrentX + dy := cy - hm.CurrentY + dist := math.Sqrt(dx*dx + dy*dy) + walkSpeed := cfg.TownNPCWalkSpeed + if walkSpeed <= 0 { + walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed + } + const minWalkMs = 300 + walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond + if walkDur < minWalkMs*time.Millisecond { + walkDur = minWalkMs * time.Millisecond + } + hm.TownCenterWalkFromX = hm.CurrentX + hm.TownCenterWalkFromY = hm.CurrentY + hm.TownCenterWalkToX = cx + hm.TownCenterWalkToY = cy + hm.TownCenterWalkStart = now + hm.TownCenterWalkArrive = now.Add(walkDur) + if sender != nil { + heading := math.Atan2(dy, dx) + sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ + X: hm.CurrentX, Y: hm.CurrentY, + TargetX: cx, TargetY: cy, + Speed: walkSpeed, Heading: heading, + }) + } + hm.SyncToHero() + return + } if hm.TownLeaveAt.IsZero() { - hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond) + restCh := cfg.TownAfterNPCRestChance + if restCh <= 0 { + restCh = tuning.DefaultValues().TownAfterNPCRestChance + } + if restCh > 1 { + restCh = 1 + } + if rand.Float64() < restCh { + hm.TownPlazaHealActive = true + hm.TownLeaveAt = now.Add(randomRestDuration()) + } else { + hm.TownPlazaHealActive = false + hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond) + } + } + if hm.TownPlazaHealActive { + hm.applyTownRestHeal(dtTown) } if now.Before(hm.TownLeaveAt) { + if sender != nil && hm.Hero != nil { + sender.SendToHero(heroID, "hero_state", hm.Hero) + sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) + } hm.SyncToHero() return } hm.TownLeaveAt = time.Time{} + hm.TownPlazaHealActive = false hm.LeaveTown(graph, now) hm.SyncToHero() if sender != nil { @@ -1830,53 +2028,66 @@ func ProcessSingleHeroMovementTick( hm.SyncToHero() return } - if rand.Float64() < cfg.TownNPCVisitChance { - npcID := hm.TownNPCQueue[0] - hm.TownNPCQueue = hm.TownNPCQueue[1:] - if npc, ok := graph.NPCByID[npcID]; ok { - npcWX, npcWY, posOk := graph.NPCWorldPos(npcID, hm.CurrentTownID) - if !posOk { - if town := graph.Towns[hm.CurrentTownID]; town != nil { - npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY - } - } - standoff := cfg.TownNPCStandoffWorld - if standoff <= 0 { - standoff = tuning.DefaultValues().TownNPCStandoffWorld - } - toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff) - dx := toX - hm.CurrentX - dy := toY - hm.CurrentY - dist := math.Sqrt(dx*dx + dy*dy) - walkSpeed := cfg.TownNPCWalkSpeed - if walkSpeed <= 0 { - walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed - } - const minWalkMs = 300 - walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond - if walkDur < minWalkMs*time.Millisecond { - walkDur = minWalkMs * time.Millisecond - } - hm.TownNPCWalkTargetID = npcID - hm.TownNPCWalkFromX = hm.CurrentX - hm.TownNPCWalkFromY = hm.CurrentY - hm.TownNPCWalkToX = toX - hm.TownNPCWalkToY = toY - hm.TownNPCWalkStart = now - hm.TownNPCWalkArrive = now.Add(walkDur) - if sender != nil { - heading := math.Atan2(dy, dx) - sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ - X: hm.CurrentX, Y: hm.CurrentY, - TargetX: toX, TargetY: toY, - Speed: walkSpeed, Heading: heading, - }) + if rand.Float64() >= cfg.TownNPCVisitChance { + hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) + hm.SyncToHero() + return + } + approachCh := cfg.TownNPCApproachChance + if approachCh <= 0 { + approachCh = tuning.DefaultValues().TownNPCApproachChance + } + if approachCh > 1 { + approachCh = 1 + } + if rand.Float64() >= approachCh { + hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) + hm.SyncToHero() + return + } + npcID := hm.TownNPCQueue[0] + hm.TownNPCQueue = hm.TownNPCQueue[1:] + if npc, ok := graph.NPCByID[npcID]; ok { + npcWX, npcWY, posOk := graph.NPCWorldPos(npcID, hm.CurrentTownID) + if !posOk { + if town := graph.Towns[hm.CurrentTownID]; town != nil { + npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY } } - hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) - } else { - hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) + standoff := cfg.TownNPCStandoffWorld + if standoff <= 0 { + standoff = tuning.DefaultValues().TownNPCStandoffWorld + } + toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff) + dx := toX - hm.CurrentX + dy := toY - hm.CurrentY + dist := math.Sqrt(dx*dx + dy*dy) + walkSpeed := cfg.TownNPCWalkSpeed + if walkSpeed <= 0 { + walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed + } + const minWalkMs = 300 + walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond + if walkDur < minWalkMs*time.Millisecond { + walkDur = minWalkMs * time.Millisecond + } + hm.TownNPCWalkTargetID = npcID + hm.TownNPCWalkFromX = hm.CurrentX + hm.TownNPCWalkFromY = hm.CurrentY + hm.TownNPCWalkToX = toX + hm.TownNPCWalkToY = toY + hm.TownNPCWalkStart = now + hm.TownNPCWalkArrive = now.Add(walkDur) + if sender != nil { + heading := math.Atan2(dy, dx) + sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ + X: hm.CurrentX, Y: hm.CurrentY, + TargetX: toX, TargetY: toY, + Speed: walkSpeed, Heading: heading, + }) + } } + hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) hm.SyncToHero() case model.StateWalking: @@ -1885,7 +2096,7 @@ func ProcessSingleHeroMovementTick( if hadNoRoad { hm.Road = nil hm.pickDestination(graph) - hm.assignRoad(graph) + hm.assignRoad(graph, false) if sender != nil && hm.Road != nil && len(hm.Road.Waypoints) >= 2 { if route := hm.RoutePayload(); route != nil { sender.SendToHero(heroID, "route_assigned", route) diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 9d7a5f6..44581dc 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -17,9 +17,10 @@ import ( // advancing movement the same way as the online engine (without WebSocket payloads) // and resolving random encounters with SimulateOneFight. type OfflineSimulator struct { - store *storage.HeroStore - logStore *storage.LogStore - graph *RoadGraph + store *storage.HeroStore + logStore *storage.LogStore + questStore *storage.QuestStore + graph *RoadGraph interval time.Duration logger *slog.Logger // isPaused, when set, skips simulation ticks while global server time is frozen. @@ -32,10 +33,11 @@ type OfflineSimulator struct { // NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds. // isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause). // skipIfLive may be nil; if it returns true for a hero id, that hero is skipped this tick. -func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator { +func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, questStore *storage.QuestStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator { return &OfflineSimulator{ store: store, logStore: logStore, + questStore: questStore, graph: graph, interval: 30 * time.Second, logger: logger, @@ -148,6 +150,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her const maxOfflineMovementSteps = 200000 step := 0 + offlineNPC := s.offlineTownNPCInteractHook(ctx) for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps { step++ next := hm.LastMoveTick.Add(movementTickRate()) @@ -165,7 +168,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her adventureLog := func(heroID int64, msg string) { s.addLog(ctx, heroID, msg) } - ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineNPC) if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 { break } @@ -184,6 +187,103 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her return nil } +func (s *OfflineSimulator) offlineTownNPCInteractHook(ctx context.Context) TownNPCOfflineInteractHook { + return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { + return s.applyOfflineTownNPCVisit(ctx, heroID, hm, graph, npc, now, al) + } +} + +// applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI). +func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { + _ = graph + _ = now + cfg := tuning.Get() + inter := cfg.TownNPCInteractChance + if inter <= 0 { + inter = tuning.DefaultValues().TownNPCInteractChance + } + if inter > 1 { + inter = 1 + } + if rand.Float64() >= inter { + return false + } + h := hm.Hero + if h == nil { + return false + } + switch npc.Type { + case "merchant": + share := cfg.MerchantTownAutoSellShare + if share <= 0 || share > 1 { + share = tuning.DefaultValues().MerchantTownAutoSellShare + } + soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil) + if soldItems > 0 && al != nil { + al(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold)) + } + potionCost, _ := tuning.EffectiveNPCShopCosts() + if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 { + h.Gold -= potionCost + h.Potions++ + if al != nil { + al(heroID, fmt.Sprintf("Purchased a Healing Potion from %s.", npc.Name)) + } + } + case "healer": + _, healCost := tuning.EffectiveNPCShopCosts() + if h.HP < h.MaxHP && healCost > 0 && h.Gold >= healCost { + h.Gold -= healCost + h.HP = h.MaxHP + if al != nil { + al(heroID, fmt.Sprintf("Paid %s to restore full health.", npc.Name)) + } + } + case "quest_giver": + if s.questStore == nil { + return true + } + hqs, err := s.questStore.ListHeroQuests(ctx, heroID) + if err != nil { + s.logger.Warn("offline town npc: list hero quests", "error", err) + return true + } + taken := make(map[int64]struct{}, len(hqs)) + for _, hq := range hqs { + taken[hq.QuestID] = struct{}{} + } + offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, h.Level) + if err != nil { + s.logger.Warn("offline town npc: list quests by npc", "error", err) + return true + } + var candidates []model.Quest + for _, q := range offered { + if _, ok := taken[q.ID]; !ok { + candidates = append(candidates, q) + } + } + if len(candidates) == 0 { + if al != nil { + al(heroID, fmt.Sprintf("Checked in with %s — nothing new.", npc.Name)) + } + return true + } + pick := candidates[rand.Intn(len(candidates))] + ok, err := s.questStore.TryAcceptQuest(ctx, heroID, pick.ID) + if err != nil { + s.logger.Warn("offline town npc: try accept quest", "error", err) + return true + } + if ok && al != nil { + al(heroID, fmt.Sprintf("Accepted quest: %s", pick.Title)) + } + default: + // Other NPC types: treat as a social stop only. + } + return true +} + // addLog is a fire-and-forget helper that writes an adventure log entry. func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message string) { logCtx, cancel := context.WithTimeout(ctx, 2*time.Second) diff --git a/backend/internal/game/rest_test.go b/backend/internal/game/rest_test.go index 469e18a..810634b 100644 --- a/backend/internal/game/rest_test.go +++ b/backend/internal/game/rest_test.go @@ -63,7 +63,7 @@ func TestRoadsideRest_TriggersOnLowHP(t *testing.T) { hm.State = model.StateWalking hm.Hero.State = model.StateWalking - ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil) if hm.State != model.StateResting { t.Fatalf("expected StateResting, got %s", hm.State) @@ -89,7 +89,7 @@ func TestRoadsideRest_DoesNotTriggerAboveThreshold(t *testing.T) { hm.Hero.State = model.StateWalking hm.LastEncounterAt = now - ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil) if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside { t.Fatal("should not trigger roadside rest above threshold") @@ -107,7 +107,7 @@ func TestRoadsideRest_HealsHP(t *testing.T) { hpBefore := hm.Hero.HP tick := now.Add(10 * time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) if hm.Hero.HP <= hpBefore { t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP) @@ -128,14 +128,14 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) { hm.beginRoadsideRest(now) pastTimer := hm.RestUntil.Add(time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil) if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase) } pastReturn := hm.Excursion.ReturnUntil.Add(time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil) if hm.State != model.StateWalking { t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind) @@ -153,7 +153,7 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) { // Tick past the Out phase so the hero is in Wild phase where HP threshold is checked. tick := hm.Excursion.OutUntil.Add(time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected excursion Return phase after HP threshold exit, got %s", hm.Excursion.Phase) @@ -192,7 +192,7 @@ func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) { hm.beginExcursion(now) tick := hm.Excursion.OutUntil.Add(time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) if hm.State != model.StateResting { t.Fatalf("expected StateResting, got %s", hm.State) @@ -218,7 +218,7 @@ func TestAdventureInlineRest_HealsHP(t *testing.T) { hpBefore := hm.Hero.HP tick := now.Add(10 * time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) if hm.Hero.HP <= hpBefore { t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP) @@ -243,7 +243,7 @@ func TestAdventureInlineRest_ExitsByHPTarget(t *testing.T) { hm.beginAdventureInlineRest(now) tick := now.Add(time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) if hm.State != model.StateWalking { t.Fatalf("expected StateWalking after HP target, got %s", hm.State) @@ -263,7 +263,7 @@ func TestAdventureInlineRest_ExitsByExcursionEnd(t *testing.T) { hm.beginAdventureInlineRest(now) pastReturn := hm.Excursion.ReturnUntil.Add(time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil) if hm.State != model.StateWalking { t.Fatalf("expected StateWalking after excursion end, got %s", hm.State) @@ -566,7 +566,7 @@ func TestExcursion_FreezesRoadWaypointDuringSession(t *testing.T) { wildMid := hm.Excursion.OutUntil.Add(hm.Excursion.WildUntil.Sub(hm.Excursion.OutUntil) / 2) for i := 0; i < 5; i++ { tick := wildMid.Add(time.Duration(i) * time.Second) - ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) if hm.Excursion.Phase == model.ExcursionNone { t.Fatalf("excursion ended unexpectedly at tick %v", tick) } @@ -591,7 +591,7 @@ func TestLowHP_DoesNotStartRestWhileFighting(t *testing.T) { hm.State = model.StateFighting hm.Hero.State = model.StateFighting - ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil) if hm.State != model.StateFighting { t.Fatalf("expected StateFighting unchanged, got %s", hm.State) diff --git a/backend/internal/model/town_pause.go b/backend/internal/model/town_pause.go index 1c863ce..a991ccc 100644 --- a/backend/internal/model/town_pause.go +++ b/backend/internal/model/town_pause.go @@ -28,6 +28,15 @@ type TownPausePersisted struct { NPCWalkStart *time.Time `json:"npcWalkStart,omitempty"` NPCWalkArrive *time.Time `json:"npcWalkArrive,omitempty"` + // Plaza: walk to town center after NPC tour, then wait/rest before leaving. + TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"` + CenterWalkFromX float64 `json:"centerWalkFromX,omitempty"` + CenterWalkFromY float64 `json:"centerWalkFromY,omitempty"` + CenterWalkToX float64 `json:"centerWalkToX,omitempty"` + CenterWalkToY float64 `json:"centerWalkToY,omitempty"` + CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"` + CenterWalkArrive *time.Time `json:"centerWalkArrive,omitempty"` + // Excursion (mini-adventure) session persisted for reconnect / offline resume. Excursion *ExcursionPersisted `json:"excursion,omitempty"` } diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index 96061ee..88e0e5c 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -373,6 +373,19 @@ func (s *QuestStore) AcceptQuest(ctx context.Context, heroID int64, questID int6 return nil } +// TryAcceptQuest inserts an accepted hero_quest row when none exists yet. Returns whether a row was inserted. +func (s *QuestStore) TryAcceptQuest(ctx context.Context, heroID int64, questID int64) (bool, error) { + tag, err := s.pool.Exec(ctx, ` + INSERT INTO hero_quests (hero_id, quest_id, status, progress, accepted_at) + VALUES ($1, $2, 'accepted', 0, now()) + ON CONFLICT (hero_id, quest_id) DO NOTHING + `, heroID, questID) + if err != nil { + return false, fmt.Errorf("try accept quest: %w", err) + } + return tag.RowsAffected() > 0, nil +} + // ListHeroQuests returns all quests for the hero with their quest template joined. func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.HeroQuest, error) { rows, err := s.pool.Query(ctx, ` diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 375be30..7e32b01 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -23,6 +23,12 @@ type Values struct { TownRestHPPerS float64 `json:"townRestHpPerSecond"` TownArrivalRadius float64 `json:"townArrivalRadius"` TownNPCVisitChance float64 `json:"townNpcVisitChance"` + // TownNPCApproachChance: second roll after a visit timer fires — whether the hero commits to walking + // toward the next queued NPC. 1.0 = same as legacy (only TownNPCVisitChance gates approach). + TownNPCApproachChance float64 `json:"townNpcApproachChance"` + // TownNPCInteractChance: offline only — after reaching an NPC, probability of “using” services + // (buy potion, full heal, accept a quest) instead of walking past. + TownNPCInteractChance float64 `json:"townNpcInteractChance"` TownNPCRollMinMs int64 `json:"townNpcRollMinMs"` TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` TownNPCRetryMs int64 `json:"townNpcRetryMs"` @@ -31,6 +37,9 @@ type Values struct { TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` // TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach). TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"` + // TownAfterNPCRestChance: after the NPC tour, at town center — probability of a full town rest + // (same duration/regen as towns without NPCs). Otherwise only a short TownNPCPauseMs wait. + TownAfterNPCRestChance float64 `json:"townAfterNpcRestChance"` WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"` MerchantCostBase int64 `json:"merchantCostBase"` @@ -212,6 +221,8 @@ func DefaultValues() Values { TownRestHPPerS: 0.002, TownArrivalRadius: 0.5, TownNPCVisitChance: 0.78, + TownNPCApproachChance: 1.0, + TownNPCInteractChance: 0.65, TownNPCRollMinMs: 800, TownNPCRollMaxMs: 2600, TownNPCRetryMs: 450, @@ -219,6 +230,7 @@ func DefaultValues() Values { TownNPCLogIntervalMs: 5_000, TownNPCWalkSpeed: 3.0, TownNPCStandoffWorld: 0.65, + TownAfterNPCRestChance: 0.78, WanderingMerchantPromptTimeoutMs: 15_000, MerchantCostBase: 20, MerchantCostPerLevel: 5,