update npc interaction

master
Denis Ranneft 1 month ago
parent bd1a636086
commit 41e246b2f1

@ -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() {

@ -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
}

@ -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,24 +1853,31 @@ 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
}
if fullVisit {
hm.TownVisitNPCName = npc.Name
hm.TownVisitNPCType = npc.Type
hm.TownVisitStartedAt = now
hm.TownVisitLogsEmitted = 0
if npc.Type == "merchant" {
legacyMerchantSell := npc.Type == "merchant" && (sender != nil || townNPCOfflineInteract == nil)
if legacyMerchantSell {
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
@ -1764,8 +1888,16 @@ func ProcessSingleHeroMovementTick(
}
}
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)
}
} else {
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() {
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,7 +2028,23 @@ func ProcessSingleHeroMovementTick(
hm.SyncToHero()
return
}
if rand.Float64() < cfg.TownNPCVisitChance {
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 {
@ -1874,9 +2088,6 @@ func ProcessSingleHeroMovementTick(
}
}
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
} else {
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
}
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)

@ -19,6 +19,7 @@ import (
type OfflineSimulator struct {
store *storage.HeroStore
logStore *storage.LogStore
questStore *storage.QuestStore
graph *RoadGraph
interval time.Duration
logger *slog.Logger
@ -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)

@ -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)

@ -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"`
}

@ -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, `

@ -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,

Loading…
Cancel
Save