diff --git a/.cursor/rules/server-authoritative-blueprint.mdc b/.cursor/rules/server-authoritative-blueprint.mdc
index 3e0dc4c..cdef12f 100644
--- a/.cursor/rules/server-authoritative-blueprint.mdc
+++ b/.cursor/rules/server-authoritative-blueprint.mdc
@@ -31,6 +31,9 @@ All messages (both directions) use `{"type": string, "payload": object}`. Text `
| `level_up` | `newLevel`, all stat fields |
| `buff_applied` | `buffType`, `magnitude`, `durationMs`, `expiresAt` |
| `debuff_applied` | `debuffType`, `magnitude`, `durationMs`, `expiresAt` |
+| `town_tour_phase` | `phase`, `townId`, optional NPC fields, `exitPending` |
+| `town_tour_service_end` | `reason` (e.g. timeout) |
+| `town_npc_visit` | NPC stand snapshot (log/engine; UI follows `town_tour_phase`) |
### Client → Server Commands
@@ -39,6 +42,9 @@ All messages (both directions) use `{"type": string, "payload": object}`. Text `
| `request_encounter` | `{}` |
| `request_revive` | `{}` |
| `activate_buff` | `{"buffType": "rage"}` |
+| `town_tour_npc_interaction_opened` | `{}` |
+| `town_tour_npc_dialog_closed` | `{}` |
+| `town_tour_npc_interaction_closed` | `{}` |
## Critical Gaps (P0 — must fix)
diff --git a/admin-web/index.html b/admin-web/index.html
index 72af481..c85cae7 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -197,6 +197,9 @@
gearFilterSubtype: "",
grantGearSearchQuery: "",
heroGrantGearCandidates: [],
+ /** NPC list for «Подойти к NPC» (town tour admin); from GET quests/towns/{id}/npcs */
+ townTourApproachNpcs: [],
+ townTourApproachNpcTownId: null,
heroGrantFilterSlot: "",
heroGrantFilterRarity: "",
heroGrantFilterSubtype: "",
@@ -1139,13 +1142,35 @@
if (h.currentTownId != null) rows.push(`
currentTownId${e(h.currentTownId)}
`);
if (h.destinationTownId != null) rows.push(`destinationTownId${e(h.destinationTownId)}
`);
if (h.restKind) rows.push(``);
+ if (h.excursionKind) {
+ rows.push(`excursionKind (hero)${e(h.excursionKind)}
`);
+ }
if (live && live.online) {
+ if (live.excursionKind) rows.push(`excursionKind (live)${e(live.excursionKind)}
`);
+ if (live.excursionPhase) rows.push(`excursionPhase${e(live.excursionPhase)}
`);
if (live.restUntil) rows.push(`отдых / restUntil${statusCountdownLine(live.restUntil)}
`);
if (live.townLeaveAt) rows.push(`в городе до выхода${statusCountdownLine(live.townLeaveAt)}
`);
if (live.nextTownNPCRollAt) rows.push(`след. событие NPC в городе${statusCountdownLine(live.nextTownNPCRollAt)}
`);
if (live.wanderingMerchantDeadline) {
rows.push(`окно бродячего торговца${statusCountdownLine(live.wanderingMerchantDeadline)}
`);
}
+ const tt = live.townTour;
+ if (tt && typeof tt === "object") {
+ rows.push(`Экскурсия по городу (town tour)
`);
+ if (tt.phase) rows.push(``);
+ if (tt.npcId) rows.push(``);
+ if (tt.townTourEndsAt) rows.push(`конец пребывания в городе${statusCountdownLine(tt.townTourEndsAt)}
`);
+ if (tt.wanderNextAt) rows.push(`след. смена аттрактора${statusCountdownLine(tt.wanderNextAt)}
`);
+ if (tt.townWelcomeUntil) rows.push(`welcome до${statusCountdownLine(tt.townWelcomeUntil)}
`);
+ if (tt.townServiceUntil) rows.push(`service до${statusCountdownLine(tt.townServiceUntil)}
`);
+ if (tt.townRestUntil) rows.push(`отдых в туре${statusCountdownLine(tt.townRestUntil)}
`);
+ if (tt.townExitPending) rows.push(`выход из городаожидает безопасной фазы
`);
+ if (tt.townTourDialogOpen) rows.push(`UINPCDialog открыт (таймеры сдвигаются)
`);
+ if (tt.townTourInteractionOpen) rows.push(``);
+ if (tt.townTourStandX != null && tt.townTourStandY != null) {
+ rows.push(`stand${e(tt.townTourStandX)}, ${e(tt.townTourStandY)}
`);
+ }
+ }
}
if (tp) {
if (tp.restUntil) rows.push(`отдых (из БД)${e(tp.restKind || "")}: ${statusCountdownLine(tp.restUntil)}
`);
@@ -1202,8 +1227,47 @@
state.selectedHeroId = heroId;
const [hero, gear, quests] = await Promise.all([api(`heroes/${heroId}`), api(`heroes/${heroId}/gear`), api(`heroes/${heroId}/quests`)]);
state.selectedHero = hero; state.gear = gear; state.quests = quests;
+ const newTid = hero.currentTownId;
+ if (Number(state.townTourApproachNpcTownId) !== Number(newTid)) {
+ state.townTourApproachNpcs = [];
+ state.townTourApproachNpcTownId = null;
+ }
render();
}
+ async function loadTownTourApproachNpcs() {
+ const h = state.selectedHero;
+ if (!h || !state.selectedHeroId) {
+ setMessage("Сначала выберите героя");
+ return;
+ }
+ const townId = h.currentTownId;
+ if (!townId) {
+ setMessage("У героя нет currentTownId (не в городе?)");
+ return;
+ }
+ const data = await api(`quests/towns/${townId}/npcs`);
+ state.townTourApproachNpcs = data.npcs || [];
+ state.townTourApproachNpcTownId = townId;
+ setMessage(`NPC города #${townId}: ${state.townTourApproachNpcs.length}`);
+ render();
+ }
+ async function townTourApproachSelectedNpc() {
+ if (!state.selectedHeroId) return;
+ const sel = document.getElementById("town-tour-approach-npc-select");
+ const raw = sel && sel.value ? String(sel.value).trim() : "";
+ const npcId = raw ? Number(raw) : 0;
+ if (!npcId) {
+ setMessage("Выберите NPC в списке");
+ return;
+ }
+ await api(`heroes/${state.selectedHeroId}/town-tour-approach-npc`, {
+ method: "POST",
+ body: JSON.stringify({ npcId }),
+ });
+ await loadHero(state.selectedHeroId);
+ startHeroMovementPoll(60);
+ setMessage(`Подход к NPC #${npcId} (town tour)`);
+ }
async function heroAction(action, body = {}, pollMovement = false) {
if (!state.selectedHeroId) return;
await api(`heroes/${state.selectedHeroId}/${action}`, { method: "POST", body: JSON.stringify(body) });
@@ -2031,6 +2095,7 @@
let heroExtra = "";
let teleportOpts = ``;
+ let townTourApproachPanel = "";
if (state.selectedHeroId) {
const rowsDbH = state.contentGearRows || [];
const rowsCatH = state.gearCatalog || [];
@@ -2049,6 +2114,22 @@
const hgSubOpts = `` + hgSubs.map(s => ``).join("");
const hgRarOpts = `` + hgRars.map(s => ``).join("");
teleportOpts = `` + (state.teleportTowns || []).map(t => ``).join("");
+ const liveMov = h.adminLiveMovement;
+ const showTownTourAdmin = h.excursionKind === "town" && liveMov && liveMov.online;
+ const npcListApproach = state.townTourApproachNpcs || [];
+ const npcOptsApproach = npcListApproach.map(n =>
+ ``
+ ).join("");
+ townTourApproachPanel = showTownTourAdmin ? `
+
+
Экскурсия по городу
+
Только для онлайн-героя с excursionKind=town. Текущий город: ${e(h.currentTownId)}. Детали — блок «Путь, город, отдых».
+
+
+
+
+
+
` : "";
const equipped = state.gear?.equipped || {};
const inventory = state.gear?.inventory || [];
const slotRows = Object.keys(equipped).sort().map(slot => `
@@ -2214,6 +2295,7 @@
Города из графа (GET /admin/towns). Герой жив и не в бою.
+ ${townTourApproachPanel}
diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go
index 33099d1..d82eb16 100644
--- a/backend/internal/game/engine.go
+++ b/backend/internal/game/engine.go
@@ -400,6 +400,12 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) {
e.handleNPCAlmsAccept(msg)
case "npc_alms_decline":
e.handleNPCAlmsDecline(msg)
+ case "town_tour_npc_dialog_closed":
+ e.handleTownTourNPCDialogClosed(msg)
+ case "town_tour_npc_interaction_opened":
+ e.handleTownTourNPCInteractionOpened(msg)
+ case "town_tour_npc_interaction_closed":
+ e.handleTownTourNPCInteractionClosed(msg)
default:
// Commands like accept_quest, claim_quest, npc_interact etc.
// are handled by their respective REST handlers for now.
@@ -855,6 +861,26 @@ func (e *Engine) ApplyAdminForceLeaveTown(heroID int64) (*model.Hero, bool) {
return h, true
}
+// ApplyAdminTownTourApproachNPC forces npc_approach toward a specific NPC during ExcursionKindTown (hero must be online).
+func (e *Engine) ApplyAdminTownTourApproachNPC(heroID, npcID int64) (*model.Hero, error) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ hm, ok := e.movements[heroID]
+ if !ok || e.roadGraph == nil {
+ return nil, fmt.Errorf("hero not online or graph missing")
+ }
+ now := time.Now()
+ if err := hm.AdminTownTourApproachNPC(e.roadGraph, npcID, now); err != nil {
+ return nil, err
+ }
+ hm.SyncToHero()
+ h := hm.Hero
+ if e.sender != nil {
+ NotifyTownTourClients(e.sender, heroID, hm, e.roadGraph, now)
+ }
+ return h, nil
+}
+
// ApplyAdminStartRest puts an online hero into town-style rest at the current location.
func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
@@ -1987,7 +2013,7 @@ func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
}
}
-// SetTownNPCUILock freezes town NPC visit narration while the client shows shop or quest UI.
+// SetTownNPCUILock freezes town tour welcome/service timers while the client shows NPCDialog.
func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
if e == nil {
return
@@ -1998,11 +2024,14 @@ func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
if hm == nil {
return
}
+ if hm.Excursion.Kind == model.ExcursionKindTown {
+ hm.townTourSetDialogOpen(locked)
+ return
+ }
hm.TownNPCUILock = locked
}
-// SkipTownNPCNarrationAfterDialog ends the current town NPC visit narration immediately when
-// the client closes shop / healer / quest UI (next tick proceeds to the next NPC or plaza).
+// SkipTownNPCNarrationAfterDialog applies town tour dialog-closed semantics (legacy name for REST).
func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
if e == nil {
return
@@ -2010,10 +2039,49 @@ func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
e.mu.Lock()
defer e.mu.Unlock()
hm := e.movements[heroID]
- if hm == nil {
+ if hm == nil || e.roadGraph == nil {
return
}
- hm.skipTownNPCNarrationForDialogClose(time.Now())
+ hm.townTourNPCDialogClosed(time.Now(), e.roadGraph)
+}
+
+func (e *Engine) handleTownTourNPCDialogClosed(msg IncomingMessage) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ hm, ok := e.movements[msg.HeroID]
+ if !ok || e.roadGraph == nil {
+ return
+ }
+ hm.townTourNPCDialogClosed(time.Now(), e.roadGraph)
+ if e.sender != nil && hm.Hero != nil {
+ e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
+ }
+}
+
+func (e *Engine) handleTownTourNPCInteractionOpened(msg IncomingMessage) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ hm, ok := e.movements[msg.HeroID]
+ if !ok || e.roadGraph == nil {
+ return
+ }
+ hm.townTourNPCInteractionOpened(time.Now(), e.roadGraph)
+ if e.sender != nil && hm.Hero != nil {
+ e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
+ }
+}
+
+func (e *Engine) handleTownTourNPCInteractionClosed(msg IncomingMessage) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ hm, ok := e.movements[msg.HeroID]
+ if !ok || e.roadGraph == nil {
+ return
+ }
+ hm.townTourNPCInteractionClosed(time.Now(), e.roadGraph)
+ if e.sender != nil && hm.Hero != nil {
+ e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
+ }
}
// SetMerchantStock replaces ephemeral merchant offers for a hero (copies items, ids cleared).
diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go
index b98cbb7..0baa687 100644
--- a/backend/internal/game/movement.go
+++ b/backend/internal/game/movement.go
@@ -120,6 +120,9 @@ type HeroMovement struct {
// lastTownPausePersistSignature tracks the last persisted excursion/rest snapshot so we can
// persist only on meaningful changes (start/end/phase change).
lastTownPausePersistSignature townPausePersistSignature
+
+ // sentTownTourWireSig avoids spamming town_tour_phase when nothing changed.
+ sentTownTourWireSig string
}
// townPausePersistSignature captures the excursion/rest fields that should trigger persistence.
@@ -539,6 +542,14 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
hm.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil)
hm.TownLeaveAt = shift(hm.TownLeaveAt)
+ if hm.Excursion.Kind == model.ExcursionKindTown {
+ ex := &hm.Excursion
+ ex.TownTourEndsAt = shift(ex.TownTourEndsAt)
+ ex.WanderNextAt = shift(ex.WanderNextAt)
+ ex.TownWelcomeUntil = shift(ex.TownWelcomeUntil)
+ ex.TownServiceUntil = shift(ex.TownServiceUntil)
+ ex.TownRestUntil = shift(ex.TownRestUntil)
+ }
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil)
@@ -744,6 +755,9 @@ func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
if !hm.Excursion.Active() {
return false
}
+ if hm.Excursion.Kind == model.ExcursionKindTown {
+ return false
+ }
if hm.State == model.StateFighting {
return false
}
@@ -870,7 +884,7 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
}
func (hm *HeroMovement) excursionUsesAttractors() bool {
- return hm != nil && hm.Excursion.Active() && hm.Excursion.Kind != model.ExcursionKindNone
+ return hm != nil && hm.Excursion.Active() && hm.Excursion.Kind != model.ExcursionKindNone && hm.Excursion.Kind != model.ExcursionKindTown
}
func excursionArrivalEpsilon() float64 {
@@ -1123,28 +1137,20 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (mons
return false, model.Enemy{}, true
}
-// EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there
+// EnterTown transitions the hero into the destination town: town tour excursion (StateInTown) when there
// are NPCs, otherwise a short resting state (StateResting).
func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
destID := hm.DestinationTownID
hm.CurrentTownID = destID
hm.DestinationTownID = 0
hm.Road = nil
- hm.TownNPCQueue = nil
- hm.NextTownNPCRollAt = time.Time{}
- hm.TownVisitNPCName = ""
- hm.TownVisitNPCType = ""
- hm.TownVisitStartedAt = time.Time{}
- hm.TownVisitLogsEmitted = 0
- hm.TownLeaveAt = time.Time{}
- hm.TownLastNPCLingerUntil = time.Time{}
+ clearLegacyTownNPCState(hm)
hm.TownRestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
+ hm.sentTownTourWireSig = ""
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
- hm.clearNPCWalk()
hm.clearTownCenterWalk()
- hm.TownPlazaHealActive = false
ids := graph.TownNPCIDs(destID)
if len(ids) == 0 {
@@ -1155,31 +1161,20 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
return
}
- q := make([]int64, len(ids))
- copy(q, ids)
- rand.Shuffle(len(q), func(i, j int) { q[i], q[j] = q[j], q[i] })
- hm.TownNPCQueue = q
hm.State = model.StateInTown
hm.Hero.State = model.StateInTown
- hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay())
+ beginTownTourExcursion(hm, now, graph)
}
// LeaveTown transitions the hero from town to walking, picking a new destination.
func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
- hm.TownNPCQueue = nil
- hm.NextTownNPCRollAt = time.Time{}
- hm.TownVisitNPCName = ""
- hm.TownVisitNPCType = ""
- hm.TownVisitStartedAt = time.Time{}
- hm.TownVisitLogsEmitted = 0
- hm.TownLeaveAt = time.Time{}
- hm.TownLastNPCLingerUntil = time.Time{}
+ clearLegacyTownNPCState(hm)
hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
- hm.clearNPCWalk()
+ hm.sentTownTourWireSig = ""
hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false
hm.State = model.StateWalking
@@ -1329,9 +1324,19 @@ func (hm *HeroMovement) SyncToHero() {
}
hm.Hero.ExcursionPhase = model.ExcursionNone
hm.Hero.ExcursionKind = model.ExcursionKindNone
+ hm.Hero.TownTourPhase = ""
+ hm.Hero.TownTourNpcID = 0
+ hm.Hero.TownTourExitPending = false
if hm.Excursion.Active() {
- hm.Hero.ExcursionPhase = hm.Excursion.Phase
hm.Hero.ExcursionKind = hm.Excursion.Kind
+ if hm.Excursion.Kind == model.ExcursionKindTown {
+ hm.Hero.ExcursionPhase = model.ExcursionWild
+ hm.Hero.TownTourPhase = hm.Excursion.TownTourPhase
+ hm.Hero.TownTourNpcID = hm.Excursion.TownTourNpcID
+ hm.Hero.TownTourExitPending = hm.Excursion.TownExitPending
+ } else {
+ hm.Hero.ExcursionPhase = hm.Excursion.Phase
+ }
}
hm.Hero.TownPause = hm.townPauseBlob()
}
@@ -1516,6 +1521,31 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
t := s.WanderNextAt
ep.WanderNextAt = &t
}
+ if s.Kind == model.ExcursionKindTown {
+ ep.TownTourPhase = s.TownTourPhase
+ ep.TownTourNpcID = s.TownTourNpcID
+ ep.TownTourStandX = s.TownTourStandX
+ ep.TownTourStandY = s.TownTourStandY
+ ep.TownExitPending = s.TownExitPending
+ ep.TownTourDialogOpen = s.TownTourDialogOpen
+ ep.TownTourInteractionOpen = s.TownTourInteractionOpen
+ if !s.TownTourEndsAt.IsZero() {
+ t := s.TownTourEndsAt
+ ep.TownTourEndsAt = &t
+ }
+ if !s.TownWelcomeUntil.IsZero() {
+ t := s.TownWelcomeUntil
+ ep.TownWelcomeUntil = &t
+ }
+ if !s.TownServiceUntil.IsZero() {
+ t := s.TownServiceUntil
+ ep.TownServiceUntil = &t
+ }
+ if !s.TownRestUntil.IsZero() {
+ t := s.TownRestUntil
+ ep.TownRestUntil = &t
+ }
+ }
return ep
}
@@ -1609,6 +1639,27 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
if ep.WanderNextAt != nil {
hm.Excursion.WanderNextAt = *ep.WanderNextAt
}
+ if ep.Kind == string(model.ExcursionKindTown) {
+ hm.Excursion.TownTourPhase = ep.TownTourPhase
+ hm.Excursion.TownTourNpcID = ep.TownTourNpcID
+ hm.Excursion.TownTourStandX = ep.TownTourStandX
+ hm.Excursion.TownTourStandY = ep.TownTourStandY
+ hm.Excursion.TownExitPending = ep.TownExitPending
+ hm.Excursion.TownTourDialogOpen = ep.TownTourDialogOpen
+ hm.Excursion.TownTourInteractionOpen = ep.TownTourInteractionOpen
+ if ep.TownTourEndsAt != nil {
+ hm.Excursion.TownTourEndsAt = *ep.TownTourEndsAt
+ }
+ if ep.TownWelcomeUntil != nil {
+ hm.Excursion.TownWelcomeUntil = *ep.TownWelcomeUntil
+ }
+ if ep.TownServiceUntil != nil {
+ hm.Excursion.TownServiceUntil = *ep.TownServiceUntil
+ }
+ if ep.TownRestUntil != nil {
+ hm.Excursion.TownRestUntil = *ep.TownRestUntil
+ }
+ }
}
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
@@ -1676,26 +1727,6 @@ 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 townLastNpcLingerDuration() time.Duration {
- ms := tuning.Get().TownLastNpcLingerMs
- if ms <= 0 {
- ms = tuning.DefaultValues().TownLastNpcLingerMs
- }
- return time.Duration(ms) * time.Millisecond
-}
-
-// scheduleLastNPCLingerFrom starts the “stand near last NPC” window when the NPC tour queue is empty.
-func (hm *HeroMovement) scheduleLastNPCLingerFrom(now time.Time) {
- if hm == nil || hm.State != model.StateInTown || len(hm.TownNPCQueue) != 0 {
- return
- }
- hm.TownLastNPCLingerUntil = now.Add(townLastNpcLingerDuration())
-}
-
func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
if log == nil || hm.TownVisitStartedAt.IsZero() || hm.TownNPCUILock {
return
@@ -1716,28 +1747,6 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv
}
}
-// skipTownNPCNarrationForDialogClose clears the per-NPC narration window after the player
-// closes shop / healer / quest UI so the next movement tick can roll the next queued NPC or plaza rest.
-func (hm *HeroMovement) skipTownNPCNarrationForDialogClose(now time.Time) {
- if hm == nil || hm.State != model.StateInTown {
- return
- }
- if hm.TownNPCWalkTargetID != 0 {
- return
- }
- wasInVisit := !hm.TownVisitStartedAt.IsZero()
- hm.TownVisitNPCName = ""
- hm.TownVisitNPCKey = ""
- hm.TownVisitNPCType = ""
- hm.TownVisitStartedAt = time.Time{}
- hm.TownVisitLogsEmitted = 0
- hm.NextTownNPCRollAt = time.Time{}
- hm.TownNPCUILock = false
- if wasInVisit && len(hm.TownNPCQueue) == 0 {
- hm.scheduleLastNPCLingerFrom(now)
- }
-}
-
// --- Excursion (mini-adventure) FSM helpers ---
func smoothstep(t float64) float64 {
@@ -1954,7 +1963,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.
+// townTourOffline, when sender is nil, resolves town NPC visits without UI during offline catch-up.
func ProcessSingleHeroMovementTick(
heroID int64,
hm *HeroMovement,
@@ -1965,7 +1974,7 @@ func ProcessSingleHeroMovementTick(
onMerchantEncounter MerchantEncounterHook,
adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist,
- townNPCOfflineInteract TownNPCOfflineInteractHook,
+ townTourOffline TownTourOfflineAtNPC,
) {
if graph == nil {
return
@@ -2076,242 +2085,12 @@ func ProcessSingleHeroMovementTick(
}
case model.StateInTown:
- cfg := tuning.Get()
- dtTown := now.Sub(hm.LastMoveTick).Seconds()
- if dtTown <= 0 {
- dtTown = movementTickRate().Seconds()
- }
- hm.LastMoveTick = now
-
- // While a town NPC dialog (shop / quests) is open, freeze narration deadlines by shifting anchors.
- if hm.TownNPCUILock && dtTown > 0 {
- shift := time.Duration(dtTown * float64(time.Second))
- if !hm.TownVisitStartedAt.IsZero() {
- hm.TownVisitStartedAt = hm.TownVisitStartedAt.Add(shift)
- }
- if !hm.NextTownNPCRollAt.IsZero() {
- hm.NextTownNPCRollAt = hm.NextTownNPCRollAt.Add(shift)
- }
- if !hm.TownLastNPCLingerUntil.IsZero() {
- hm.TownLastNPCLingerUntil = hm.TownLastNPCLingerUntil.Add(shift)
- }
- }
-
- // --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) ---
- if hm.TownCenterWalkActive {
- walkSpeed := cfg.TownNPCWalkSpeed
- if walkSpeed <= 0 {
- walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
- }
- arrived := hm.stepTowardWorldPoint(dtTown, hm.TownCenterWalkToX, hm.TownCenterWalkToY, walkSpeed)
- if arrived {
- 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 if sender != nil {
- 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 (attractor stepping) ---
- if hm.TownNPCWalkTargetID != 0 {
- walkSpeed := cfg.TownNPCWalkSpeed
- if walkSpeed <= 0 {
- walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
- }
- arrived := hm.stepTowardWorldPoint(dtTown, hm.TownNPCWalkToX, hm.TownNPCWalkToY, walkSpeed)
- if arrived {
- // Arrived at stand point (near NPC) — fire the visit event.
- npcID := hm.TownNPCWalkTargetID
- standX := hm.TownNPCWalkToX
- standY := hm.TownNPCWalkToY
- hm.clearNPCWalk()
-
- if npc, ok := graph.NPCByID[npcID]; ok {
- fullVisit := false
- townNameKey := ""
- if tt := graph.Towns[hm.CurrentTownID]; tt != nil {
- townNameKey = tt.NameKey
- }
- if sender != nil {
- 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: standX, WorldY: standY,
- })
- 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.TownVisitNPCKey = npc.NameKey
- 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, model.AdventureLogLine{
- Event: &model.AdventureLogEvent{
- Code: model.LogPhraseSoldItemsMerchant,
- Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
- },
- })
- }
- }
- emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
- hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
- } else {
- if adventureLog != nil {
- adventureLog(heroID, model.AdventureLogLine{
- Event: &model.AdventureLogEvent{
- Code: model.LogPhraseNPCSkippedVisit,
- Args: map[string]any{"npcKey": npc.NameKey},
- },
- })
- }
- hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
- }
- } else {
- hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
- }
- } else if sender != nil {
- dx := hm.TownNPCWalkToX - hm.CurrentX
- dy := hm.TownNPCWalkToY - hm.CurrentY
- heading := math.Atan2(dy, dx)
- sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
- X: hm.CurrentX, Y: hm.CurrentY,
- TargetX: hm.TownNPCWalkToX, TargetY: hm.TownNPCWalkToY,
- Speed: walkSpeed, Heading: heading,
- })
- }
- hm.SyncToHero()
+ if hm.Excursion.Kind == model.ExcursionKindTown {
+ processTownTourMovement(heroID, hm, graph, now, sender, adventureLog, townTourOffline)
return
}
-
- // NPC visit pause ended: clear visit log state before the next roll.
- if !hm.TownNPCUILock && !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
- hm.TownVisitNPCName = ""
- hm.TownVisitNPCKey = ""
- hm.TownVisitNPCType = ""
- hm.TownVisitStartedAt = time.Time{}
- hm.TownVisitLogsEmitted = 0
- if len(hm.TownNPCQueue) == 0 {
- hm.scheduleLastNPCLingerFrom(now)
- }
- }
- 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
- }
- // After the last NPC: stay at the stand point until linger ends and dialog is not open.
- if !hm.TownLastNPCLingerUntil.IsZero() {
- if hm.TownNPCUILock || now.Before(hm.TownLastNPCLingerUntil) {
- if sender != nil && hm.Hero != nil {
- sender.SendToHero(heroID, "hero_state", hm.Hero)
- sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
- X: hm.CurrentX, Y: hm.CurrentY,
- TargetX: hm.CurrentX, TargetY: hm.CurrentY,
- Speed: 0, Heading: 0,
- })
- }
- hm.SyncToHero()
- return
- }
- hm.TownLastNPCLingerUntil = time.Time{}
- }
- 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
- walkSpeed := cfg.TownNPCWalkSpeed
- if walkSpeed <= 0 {
- walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
- }
- hm.TownCenterWalkToX = cx
- hm.TownCenterWalkToY = cy
- hm.TownCenterWalkActive = true
- 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
+ // Legacy in-town row without town excursion: force exit.
+ if graph != nil {
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
@@ -2320,63 +2099,8 @@ func ProcessSingleHeroMovementTick(
sender.SendToHero(heroID, "route_assigned", route)
}
}
- return
}
- if now.Before(hm.NextTownNPCRollAt) {
- hm.SyncToHero()
- return
- }
- 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
- }
- }
- 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
- walkSpeed := cfg.TownNPCWalkSpeed
- if walkSpeed <= 0 {
- walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
- }
- hm.TownNPCWalkTargetID = npcID
- hm.TownNPCWalkToX = toX
- hm.TownNPCWalkToY = toY
- 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()
+ return
case model.StateWalking:
cfg := tuning.Get()
diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go
index a5df784..25d47cc 100644
--- a/backend/internal/game/offline.go
+++ b/backend/internal/game/offline.go
@@ -206,7 +206,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
const maxOfflineMovementSteps = 200000
step := 0
- offlineNPC := s.offlineTownNPCInteractHook(ctx)
+ offlineTownTour := s.offlineTownTourAtNPC(ctx)
for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps {
step++
next := hm.LastMoveTick.Add(movementTickRate())
@@ -226,7 +226,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
adventureLog := func(heroID int64, line model.AdventureLogLine) {
s.addLog(ctx, heroID, line)
}
- ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineNPC)
+ ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineTownTour)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break
}
@@ -252,9 +252,9 @@ func (s *OfflineSimulator) SimulateHeroAt(ctx context.Context, hero *model.Hero,
return s.simulateHeroTick(ctx, hero, now, persist)
}
-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)
+func (s *OfflineSimulator) offlineTownTourAtNPC(ctx context.Context) TownTourOfflineAtNPC {
+ return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
+ s.applyOfflineTownTourNPCVisit(ctx, heroID, hm, graph, npc, now, al)
}
}
@@ -281,74 +281,102 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
}
}
-// applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI).
-// With no live WebSocket, service use (gear, potion, heal, quest accept) each fires independently with probability 0.2 when affordable.
-func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
- _ = 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
- }
+// applyOfflineTownTourNPCVisit resolves one town-tour NPC stop without UI: quest → merchant upgrade → healer heal → potion → fallbacks.
+func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
h := hm.Hero
if h == nil {
- return false
+ return
}
var town *model.Town
if graph != nil {
town = graph.Towns[hm.CurrentTownID]
}
townLv := TownEffectiveLevel(town)
- const offlineServiceChance = 0.2
+ cfg := tuning.Get()
- switch npc.Type {
- case "merchant":
- share := cfg.MerchantTownAutoSellShare
- if share <= 0 || share > 1 {
- share = tuning.DefaultValues().MerchantTownAutoSellShare
+ tryQuest := func() bool {
+ if npc.Type != "quest_giver" || s.questStore == nil {
+ return false
}
- soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
- if soldItems > 0 && al != nil {
- al(heroID, model.AdventureLogLine{
- Event: &model.AdventureLogEvent{
- Code: model.LogPhraseSoldItemsMerchant,
- Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
- },
- })
+ hqs, err := s.questStore.ListHeroQuests(ctx, heroID)
+ if err != nil {
+ s.logger.Warn("offline town tour: list hero quests", "error", err)
+ return false
}
- gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
- if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance {
- h.Gold -= gearCost
- drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now)
+ taken := make(map[int64]struct{}, len(hqs))
+ for _, hq := range hqs {
+ taken[hq.QuestID] = struct{}{}
+ }
+ offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv)
+ if err != nil {
+ s.logger.Warn("offline town tour: list quests by npc", "error", err)
+ return false
+ }
+ for _, q := range offered {
+ if _, ok := taken[q.ID]; ok {
+ continue
+ }
+ ok, err := s.questStore.TryAcceptQuest(ctx, heroID, q.ID)
if err != nil {
- h.Gold += gearCost
- s.logger.Warn("offline town merchant gear", "hero_id", heroID, "error", err)
- } else if al != nil && drop != nil {
- townKey := ""
- if town != nil {
- townKey = town.NameKey
+ s.logger.Warn("offline town tour: try accept quest", "error", err)
+ return false
+ }
+ if ok && al != nil {
+ qk := q.QuestKey
+ if qk == "" {
+ qk = fmt.Sprintf("quest.%d", q.ID)
}
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
- Code: model.LogPhraseBoughtGearTownMerchant,
- Args: map[string]any{
- "npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
- "rarity": string(drop.Rarity), "itemId": drop.ItemID,
- },
+ Code: model.LogPhraseQuestAccepted,
+ Args: map[string]any{"questKey": qk},
},
})
}
+ return ok
}
- case "healer":
- _, healCost := tuning.EffectiveNPCShopCosts()
- potionCost, _ := tuning.EffectiveNPCShopCosts()
- if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance {
+ return false
+ }
+
+ if tryQuest() {
+ return
+ }
+
+ if npc.Type == "merchant" {
+ gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
+ if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost {
+ items := RollTownMerchantStockItems(townLv, 1)
+ if len(items) > 0 && TownMerchantRollIsUpgrade(h, items[0], now) {
+ h.Gold -= gearCost
+ drop, err := ApplyPreparedTownMerchantPurchase(ctx, s.gearStore, h, items[0], now)
+ if err != nil {
+ h.Gold += gearCost
+ s.logger.Warn("offline town tour merchant gear", "hero_id", heroID, "error", err)
+ } else if al != nil && drop != nil {
+ townKey := ""
+ if town != nil {
+ townKey = town.NameKey
+ }
+ al(heroID, model.AdventureLogLine{
+ Event: &model.AdventureLogEvent{
+ Code: model.LogPhraseBoughtGearTownMerchant,
+ Args: map[string]any{
+ "npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
+ "rarity": string(drop.Rarity), "itemId": drop.ItemID,
+ },
+ },
+ })
+ }
+ return
+ }
+ }
+ }
+
+ _, healCost := tuning.EffectiveNPCShopCosts()
+ potionCost, _ := tuning.EffectiveNPCShopCosts()
+ if npc.Type == "healer" && h.MaxHP > 0 {
+ hpFrac := float64(h.HP) / float64(h.MaxHP)
+ if hpFrac < 0.5 && healCost > 0 && h.Gold >= healCost && h.HP < h.MaxHP {
h.Gold -= healCost
h.HP = h.MaxHP
if al != nil {
@@ -359,8 +387,9 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
},
})
}
+ return
}
- if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < offlineServiceChance {
+ if potionCost > 0 && h.Gold >= potionCost {
h.Gold -= potionCost
h.Potions++
if al != nil {
@@ -371,75 +400,35 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
},
})
}
+ return
}
- 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, townLv)
- 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, model.AdventureLogLine{
- Event: &model.AdventureLogEvent{
- Code: model.LogPhraseQuestGiverChecked,
- Args: map[string]any{"npcKey": npc.NameKey},
- },
- })
- }
- return true
- }
- if rand.Float64() >= offlineServiceChance {
- if al != nil {
- al(heroID, model.AdventureLogLine{
- Event: &model.AdventureLogEvent{
- Code: model.LogPhraseQuestGiverChecked,
- Args: map[string]any{"npcKey": npc.NameKey},
- },
- })
- }
- 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 npc.Type == "merchant" {
+ share := cfg.MerchantTownAutoSellShare
+ if share <= 0 || share > 1 {
+ share = tuning.DefaultValues().MerchantTownAutoSellShare
}
- if ok && al != nil {
- qk := pick.QuestKey
- if qk == "" {
- qk = fmt.Sprintf("quest.%d", pick.ID)
- }
+ soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
+ if soldItems > 0 && al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
- Code: model.LogPhraseQuestAccepted,
- Args: map[string]any{"questKey": qk},
+ Code: model.LogPhraseSoldItemsMerchant,
+ Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
}
- default:
- // Other NPC types: treat as a social stop only.
+ return
+ }
+
+ if npc.Type == "quest_giver" && al != nil {
+ al(heroID, model.AdventureLogLine{
+ Event: &model.AdventureLogEvent{
+ Code: model.LogPhraseQuestGiverChecked,
+ Args: map[string]any{"npcKey": npc.NameKey},
+ },
+ })
}
- return true
}
// addLog is a fire-and-forget helper that writes an adventure log entry.
diff --git a/backend/internal/game/town_merchant_gear.go b/backend/internal/game/town_merchant_gear.go
index 0fd4f9d..f468bd4 100644
--- a/backend/internal/game/town_merchant_gear.go
+++ b/backend/internal/game/town_merchant_gear.go
@@ -152,3 +152,17 @@ func ApplyTownMerchantGearPurchase(ctx context.Context, gs *storage.GearStore, h
}
return ApplyPreparedTownMerchantPurchase(ctx, gs, hero, items[0], now)
}
+
+// TownMerchantRollIsUpgrade returns true if equipping the rolled item (hypothetically) raises CombatRatingAt.
+func TownMerchantRollIsUpgrade(hero *model.Hero, item *model.GearItem, now time.Time) bool {
+ if hero == nil || item == nil {
+ return false
+ }
+ hero.EnsureGearMap()
+ before := hero.CombatRatingAt(now)
+ old := hero.Gear[item.Slot]
+ hero.Gear[item.Slot] = item
+ after := hero.CombatRatingAt(now)
+ hero.Gear[item.Slot] = old
+ return after > before
+}
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index 3075b37..459b5cf 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -69,19 +69,37 @@ type heroSummary struct {
UpdatedAt time.Time `json:"updatedAt"`
}
+// adminTownTourLiveJSON is a snapshot of ExcursionKindTown for the admin UI.
+type adminTownTourLiveJSON struct {
+ Phase string `json:"phase,omitempty"`
+ NpcID int64 `json:"npcId,omitempty"`
+ TownTourEndsAt *time.Time `json:"townTourEndsAt,omitempty"`
+ WelcomeUntil *time.Time `json:"townWelcomeUntil,omitempty"`
+ ServiceUntil *time.Time `json:"townServiceUntil,omitempty"`
+ RestUntil *time.Time `json:"townRestUntil,omitempty"`
+ WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
+ ExitPending bool `json:"townExitPending,omitempty"`
+ DialogOpen bool `json:"townTourDialogOpen,omitempty"`
+ InteractionOpen bool `json:"townTourInteractionOpen,omitempty"`
+ StandX float64 `json:"townTourStandX,omitempty"`
+ StandY float64 `json:"townTourStandY,omitempty"`
+}
+
// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct {
- Online bool `json:"online"`
- MoveState string `json:"moveState,omitempty"`
- RestUntil *time.Time `json:"restUntil,omitempty"`
- TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
- NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
- CurrentTownID int64 `json:"currentTownId,omitempty"`
- DestinationTownID int64 `json:"destinationTownId,omitempty"`
- WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
- ExcursionPhase string `json:"excursionPhase,omitempty"`
- ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"`
- ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,omitempty"`
+ Online bool `json:"online"`
+ MoveState string `json:"moveState,omitempty"`
+ RestUntil *time.Time `json:"restUntil,omitempty"`
+ TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
+ NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
+ CurrentTownID int64 `json:"currentTownId,omitempty"`
+ DestinationTownID int64 `json:"destinationTownId,omitempty"`
+ WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
+ ExcursionKind string `json:"excursionKind,omitempty"`
+ ExcursionPhase string `json:"excursionPhase,omitempty"`
+ ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"`
+ ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,omitempty"`
+ TownTour *adminTownTourLiveJSON `json:"townTour,omitempty"`
}
// adminHeroDetailResponse is the full admin JSON for one hero: base hero + persisted town_pause + live movement snapshot.
@@ -157,6 +175,7 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
s.WanderingMerchantDeadline = &t
}
if hm.Excursion.Active() {
+ s.ExcursionKind = string(hm.Excursion.Kind)
s.ExcursionPhase = string(hm.Excursion.Phase)
if !hm.Excursion.WildUntil.IsZero() {
t := hm.Excursion.WildUntil
@@ -167,6 +186,39 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
s.ExcursionReturnUntil = &t
}
}
+ if hm.Excursion.Kind == model.ExcursionKindTown {
+ ex := hm.Excursion
+ tt := &adminTownTourLiveJSON{
+ Phase: ex.TownTourPhase,
+ NpcID: ex.TownTourNpcID,
+ ExitPending: ex.TownExitPending,
+ DialogOpen: ex.TownTourDialogOpen,
+ InteractionOpen: ex.TownTourInteractionOpen,
+ StandX: ex.TownTourStandX,
+ StandY: ex.TownTourStandY,
+ }
+ if !ex.TownTourEndsAt.IsZero() {
+ t := ex.TownTourEndsAt
+ tt.TownTourEndsAt = &t
+ }
+ if !ex.TownWelcomeUntil.IsZero() {
+ t := ex.TownWelcomeUntil
+ tt.WelcomeUntil = &t
+ }
+ if !ex.TownServiceUntil.IsZero() {
+ t := ex.TownServiceUntil
+ tt.ServiceUntil = &t
+ }
+ if !ex.TownRestUntil.IsZero() {
+ t := ex.TownRestUntil
+ tt.RestUntil = &t
+ }
+ if !ex.WanderNextAt.IsZero() {
+ t := ex.WanderNextAt
+ tt.WanderNextAt = &t
+ }
+ s.TownTour = tt
+ }
return s
}
@@ -2009,6 +2061,48 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) {
h.writeAdminHeroDetail(w, hero2)
}
+// TownTourApproachNPC forces npc_approach toward a specific NPC during ExcursionKindTown (online heroes only).
+// POST /admin/heroes/{heroId}/town-tour-approach-npc body: {"npcId":123}
+func (h *AdminHandler) TownTourApproachNPC(w http.ResponseWriter, r *http.Request) {
+ heroID, err := parseHeroID(r)
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
+ return
+ }
+ if h.isHeroInCombat(w, heroID) {
+ return
+ }
+ var req struct {
+ NpcID int64 `json:"npcId"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NpcID <= 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: need {\"npcId\": positive number}"})
+ return
+ }
+ if h.engine.GetMovements(heroID) == nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be online (active WS movement session)"})
+ return
+ }
+ out, err := h.engine.ApplyAdminTownTourApproachNPC(heroID, req.NpcID)
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ out.RefreshDerivedCombatStats(time.Now())
+ if err := h.store.Save(r.Context(), out); err != nil {
+ h.logger.Error("admin: save after town-tour-approach-npc", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
+ return
+ }
+ h.logger.Info("admin: town tour approach npc", "hero_id", heroID, "npc_id", req.NpcID)
+ heroAfter, err := h.store.GetByID(r.Context(), heroID)
+ if err != nil || heroAfter == nil {
+ h.writeAdminHeroDetail(w, out)
+ return
+ }
+ h.writeAdminHeroDetail(w, heroAfter)
+}
+
// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online.
// POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go
index 7cc6290..6a9e1ab 100644
--- a/backend/internal/handler/npc.go
+++ b/backend/internal/handler/npc.go
@@ -884,8 +884,7 @@ func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
- Open bool `json:"open"`
- AdvanceTownVisit bool `json:"advanceTownVisit"`
+ Open bool `json:"open"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
@@ -902,14 +901,10 @@ func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) {
return
}
if h.engine != nil {
- if !req.Open && req.AdvanceTownVisit {
- h.engine.SkipTownNPCNarrationAfterDialog(hero.ID)
+ h.engine.SetTownNPCUILock(hero.ID, req.Open)
+ if !req.Open {
h.engine.ClearMerchantStock(hero.ID)
- } else {
- h.engine.SetTownNPCUILock(hero.ID, req.Open)
- if !req.Open {
- h.engine.ClearMerchantStock(hero.ID)
- }
+ h.engine.SkipTownNPCNarrationAfterDialog(hero.ID)
}
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
diff --git a/backend/internal/model/excursion.go b/backend/internal/model/excursion.go
index cdae18a..39b6d02 100644
--- a/backend/internal/model/excursion.go
+++ b/backend/internal/model/excursion.go
@@ -4,6 +4,7 @@ import "time"
// ExcursionPhase tracks where the hero is within a mini-adventure session.
// The lifecycle is: Out → Wild → Return → (back to road, phase cleared).
+// For KindTown, Phase is usually ExcursionWild while using attractor movement during wander/rest.
type ExcursionPhase string
const (
@@ -13,17 +14,29 @@ const (
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible)
)
-// ExcursionKind distinguishes roadside rest vs walking adventure sessions.
+// ExcursionKind distinguishes roadside rest vs walking adventure vs in-town tour.
type ExcursionKind string
const (
- ExcursionKindNone ExcursionKind = ""
- ExcursionKindRoadside ExcursionKind = "roadside"
+ ExcursionKindNone ExcursionKind = ""
+ ExcursionKindRoadside ExcursionKind = "roadside"
ExcursionKindAdventure ExcursionKind = "adventure"
+ ExcursionKindTown ExcursionKind = "town"
)
-// ExcursionSession holds the live state of an active mini-adventure (off-road excursion).
-// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
+// TownTourPhase is the sub-state machine while ExcursionKind == town (StateInTown).
+type TownTourPhase string
+
+const (
+ TownTourPhaseWander TownTourPhase = "wander"
+ TownTourPhaseNpcApproach TownTourPhase = "npc_approach"
+ TownTourPhaseNpcWelcome TownTourPhase = "npc_welcome"
+ TownTourPhaseNpcService TownTourPhase = "npc_service"
+ TownTourPhaseRest TownTourPhase = "rest"
+)
+
+// ExcursionSession holds the live state of an active mini-adventure (off-road excursion) or town tour.
+// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued (except Kind for town cleared on leave).
type ExcursionSession struct {
Kind ExcursionKind
Phase ExcursionPhase
@@ -43,41 +56,79 @@ type ExcursionSession struct {
RoadFreezeFraction float64
// Attractor-based movement (Kind != ""): hero walks in world space toward AttractorX/Y.
- StartX, StartY float64
+ StartX, StartY float64
AttractorX, AttractorY float64
- AttractorSet bool
+ AttractorSet bool
// Adventure-only: wall-time when wandering should end (then return to road).
AdventureEndsAt time.Time
- // Adventure: next time to pick a new wander attractor (wild phase).
+ // Adventure / town wander: next time to pick a new wander attractor (wild phase).
WanderNextAt time.Time
// PendingReturnAfterCombat: adventure timer elapsed; wait for combat end then enter return phase.
PendingReturnAfterCombat bool
+
+ // --- Town tour (Kind == ExcursionKindTown) ---
+ TownTourPhase string
+ // TownTourEndsAt: wall-time when the hero should leave the town (may defer until idle).
+ TownTourEndsAt time.Time
+ TownTourNpcID int64
+ // Stand point near NPC during approach / welcome / service.
+ TownTourStandX float64
+ TownTourStandY float64
+ // TownWelcomeUntil: npc_welcome phase deadline (30s, shifted while dialog open).
+ TownWelcomeUntil time.Time
+ // TownServiceUntil: npc_service phase max wall time (4 min, shifted while UI open).
+ TownServiceUntil time.Time
+ // TownRestUntil: in-town rest phase end.
+ TownRestUntil time.Time
+ TownExitPending bool
+ // Client has NPCDialog (welcome or service) open — shifts welcome/service deadlines.
+ TownTourDialogOpen bool
+ // Client has NPCInteraction panel open — shifts service deadline; with dialog shifts welcome too.
+ TownTourInteractionOpen bool
}
// Active reports whether an excursion session is in progress.
func (s *ExcursionSession) Active() bool {
+ if s == nil {
+ return false
+ }
+ if s.Kind == ExcursionKindTown {
+ return s.TownTourPhase != ""
+ }
return s.Phase != ExcursionNone
}
// ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the
// heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure.
type ExcursionPersisted struct {
- Kind string `json:"kind,omitempty"`
- Phase string `json:"phase,omitempty"`
- StartedAt *time.Time `json:"startedAt,omitempty"`
- OutUntil *time.Time `json:"outUntil,omitempty"`
- WildUntil *time.Time `json:"wildUntil,omitempty"`
- ReturnUntil *time.Time `json:"returnUntil,omitempty"`
- DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
- RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
- RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
- StartX float64 `json:"startX,omitempty"`
- StartY float64 `json:"startY,omitempty"`
- AttractorX float64 `json:"attractorX,omitempty"`
- AttractorY float64 `json:"attractorY,omitempty"`
- AttractorSet bool `json:"attractorSet,omitempty"`
- AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"`
- WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
- PendingReturnAfterCombat bool `json:"pendingReturnAfterCombat,omitempty"`
+ Kind string `json:"kind,omitempty"`
+ Phase string `json:"phase,omitempty"`
+ StartedAt *time.Time `json:"startedAt,omitempty"`
+ OutUntil *time.Time `json:"outUntil,omitempty"`
+ WildUntil *time.Time `json:"wildUntil,omitempty"`
+ ReturnUntil *time.Time `json:"returnUntil,omitempty"`
+ DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
+ RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
+ RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
+ StartX float64 `json:"startX,omitempty"`
+ StartY float64 `json:"startY,omitempty"`
+ AttractorX float64 `json:"attractorX,omitempty"`
+ AttractorY float64 `json:"attractorY,omitempty"`
+ AttractorSet bool `json:"attractorSet,omitempty"`
+ AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"`
+ WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
+ PendingReturnAfterCombat bool `json:"pendingReturnAfterCombat,omitempty"`
+
+ TownTourPhase string `json:"townTourPhase,omitempty"`
+ TownTourEndsAt *time.Time `json:"townTourEndsAt,omitempty"`
+ TownTourNpcID int64 `json:"townTourNpcId,omitempty"`
+ TownTourStandX float64 `json:"townTourStandX,omitempty"`
+ TownTourStandY float64 `json:"townTourStandY,omitempty"`
+ TownWelcomeUntil *time.Time `json:"townWelcomeUntil,omitempty"`
+ TownServiceUntil *time.Time `json:"townServiceUntil,omitempty"`
+ TownRestUntil *time.Time `json:"townRestUntil,omitempty"`
+ TownExitPending bool `json:"townExitPending,omitempty"`
+ TownTourDialogOpen bool `json:"townTourDialogOpen,omitempty"`
+ TownTourInteractionOpen bool `json:"townTourInteractionOpen,omitempty"`
}
diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go
index 812f178..56bb2ec 100644
--- a/backend/internal/model/hero.go
+++ b/backend/internal/model/hero.go
@@ -67,8 +67,13 @@ type Hero struct {
RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`
- // ExcursionKind is "roadside" | "adventure" during attractor-based excursions; empty otherwise.
+ // ExcursionKind is "roadside" | "adventure" | "town" during attractor-based sessions; empty otherwise.
ExcursionKind ExcursionKind `json:"excursionKind,omitempty"`
+ // TownTourPhase is set during in-town tour (wander, npc_welcome, npc_service, …); empty otherwise.
+ TownTourPhase string `json:"townTourPhase,omitempty"`
+ TownTourNpcID int64 `json:"townTourNpcId,omitempty"`
+ // TownTourExitPending: server wants to leave town after timers but waits for safe phase.
+ TownTourExitPending bool `json:"townTourExitPending,omitempty"`
// TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"`
diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go
index 04a990e..d94294a 100644
--- a/backend/internal/model/ws_message.go
+++ b/backend/internal/model/ws_message.go
@@ -174,6 +174,25 @@ type TownNPCVisitPayload struct {
WorldY float64 `json:"worldY"`
}
+// TownTourPhasePayload is sent when the in-town tour phase or NPC context changes (ExcursionKindTown).
+type TownTourPhasePayload struct {
+ Phase string `json:"phase"`
+ TownID int64 `json:"townId"`
+ TownNameKey string `json:"townNameKey,omitempty"`
+ NpcID int64 `json:"npcId,omitempty"`
+ NpcName string `json:"npcName,omitempty"`
+ NpcNameKey string `json:"npcNameKey,omitempty"`
+ NpcType string `json:"npcType,omitempty"`
+ WorldX float64 `json:"worldX,omitempty"`
+ WorldY float64 `json:"worldY,omitempty"`
+ ExitPending bool `json:"exitPending,omitempty"`
+}
+
+// TownTourServiceEndPayload is sent when the NPC service phase ends by server timeout.
+type TownTourServiceEndPayload struct {
+ Reason string `json:"reason"` // "timeout"
+}
+
// AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log.
type AdventureLogLinePayload = AdventureLogLine
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 6dc4b04..9426d81 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -98,6 +98,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/stop-adventure", adminH.StopHeroExcursion)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest)
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown)
+ r.Post("/heroes/{heroId}/town-tour-approach-npc", adminH.TownTourApproachNPC)
r.Post("/heroes/{heroId}/trigger-random-encounter", adminH.TriggerRandomEncounter)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)
diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go
index 65d7e2f..7f8d78c 100644
--- a/backend/internal/tuning/runtime.go
+++ b/backend/internal/tuning/runtime.go
@@ -43,6 +43,17 @@ type Values struct {
// (same duration/regen as towns without NPCs). Otherwise only a short TownNPCPauseMs wait.
TownAfterNPCRestChance float64 `json:"townAfterNpcRestChance"`
+ // Town tour (ExcursionKindTown): attractor retarget cadence and P(stand near NPC vs random plaza wander).
+ TownTourWanderRetargetMinMs int64 `json:"townTourWanderRetargetMinMs"`
+ TownTourWanderRetargetMaxMs int64 `json:"townTourWanderRetargetMaxMs"`
+ TownTourNpcAttractorChance float64 `json:"townTourNpcAttractorChance"`
+ TownWelcomeDurationMs int64 `json:"townWelcomeDurationMs"`
+ TownServiceMaxMs int64 `json:"townServiceMaxMs"`
+ TownRestHpThreshold float64 `json:"townRestHpThreshold"`
+ TownRestChance float64 `json:"townRestChance"`
+ TownTourRestMinMs int64 `json:"townTourRestMinMs"`
+ TownTourRestMaxMs int64 `json:"townTourRestMaxMs"`
+
WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"`
MerchantCostBase int64 `json:"merchantCostBase"`
MerchantCostPerLevel int64 `json:"merchantCostPerLevel"`
@@ -282,6 +293,15 @@ func DefaultValues() Values {
TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65,
TownAfterNPCRestChance: 0.78,
+ TownTourWanderRetargetMinMs: 5_000,
+ TownTourWanderRetargetMaxMs: 14_000,
+ TownTourNpcAttractorChance: 0.14,
+ TownWelcomeDurationMs: 30_000,
+ TownServiceMaxMs: 240_000,
+ TownRestHpThreshold: 0.8,
+ TownRestChance: 0.7,
+ TownTourRestMinMs: 240_000,
+ TownTourRestMaxMs: 360_000,
WanderingMerchantPromptTimeoutMs: 15_000,
MerchantCostBase: 20,
MerchantCostPerLevel: 5,
diff --git a/backend/migrations/000027_gear_encounter_balance.sql b/backend/migrations/000027_gear_encounter_balance.sql
index dcac89e..f64013d 100644
--- a/backend/migrations/000027_gear_encounter_balance.sql
+++ b/backend/migrations/000027_gear_encounter_balance.sql
@@ -44,7 +44,7 @@ UPDATE public.runtime_config
SET
payload = payload || jsonb_build_object(
'enemyEncounterStatMultiplier', 1.2,
- 'enemyStatMultiplierVsUnequippedHero', 0.75
+ 'enemyStatMultiplierVsUnequippedHero', 0.85
),
updated_at = now()
WHERE id = true;
diff --git a/docs/blueprint_server_authoritative.md b/docs/blueprint_server_authoritative.md
index 2cca681..d2aa296 100644
--- a/docs/blueprint_server_authoritative.md
+++ b/docs/blueprint_server_authoritative.md
@@ -124,6 +124,30 @@ Server always sends `WSMessage`. Client always sends `WSMessage`. The `readPump`
{"type":"activate_buff","payload":{"buffType":"rage"}}
```
+### Town tour (in-town excursion, `excursionKind: "town"`)
+
+While the hero is in a town with NPCs, the server runs an **ExcursionKindTown** session: attractor wandering inside the town radius, optional rest when HP is low, deferred exit when the town timer elapses, and NPC phases (`npc_approach` → `npc_welcome` → `npc_service`). The client must not infer NPC panels from proximity alone.
+
+**Server → client**
+
+- `town_tour_phase` — payload: `phase`, `townId`, `townNameKey`, optional `npcId`, `npcName`, `npcNameKey`, `npcType`, `worldX`, `worldY`, `exitPending`. Drives which UI to show (`npc_welcome` / `npc_service` vs wander/rest).
+- `town_npc_visit` — still emitted when the hero reaches the NPC stand online (adventure log / engine hooks); primary UI should follow `town_tour_phase`.
+- `town_tour_service_end` — payload: `reason` (e.g. `timeout`) when the service phase hits the server max duration.
+
+**Client → server**
+
+- `town_tour_npc_interaction_opened` — payload `{}` (welcome → service transition; interaction timers).
+- `town_tour_npc_dialog_closed` — payload `{}` (dialog dismissed; may return to wander from welcome or service).
+- `town_tour_npc_interaction_closed` — payload `{}` (interaction chip dismissed or service finished).
+
+**REST (optional, same semantics as dialog close)**
+
+- `POST /api/v1/hero/npc-dialog-pause` with `{ "open": true|false }` maps to `TownNPCUILock` / town tour dialog-open flag; on `open: false` the engine also applies dialog-closed progression (legacy name `SkipTownNPCNarrationAfterDialog`).
+
+**Offline**
+
+- No WebSocket: when the hero hits an NPC stand during offline catch-up, `applyOfflineTownTourNPCVisit` resolves deterministically (quest accept if possible, else merchant upgrade only if rolled gear improves combat rating, else healer heal if HP < 50%, else potion, else merchant autosell / quest-giver checked log).
+
### Step-by-Step Changes
#### 1. Fix WS protocol — `internal/handler/ws.go`
diff --git a/docs/spec-server-authoritative.md b/docs/spec-server-authoritative.md
index 576d929..9324022 100644
--- a/docs/spec-server-authoritative.md
+++ b/docs/spec-server-authoritative.md
@@ -6,6 +6,8 @@ Date: 2026-03-27
This spec is the contract between the backend and frontend agents.
Each phase is independently deployable. Phases must ship in order.
+**Unified simulation (online/offline):** gameplay ticks run only in the Go `Engine`; WS is observe/commands. See [engine_unified_offline_online.md](./engine_unified_offline_online.md).
+
---
## 1. WebSocket Message Protocol
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 14e02c1..0dd19b7 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -10,6 +10,9 @@ import {
sendRevive,
sendNPCAlmsAccept,
sendNPCAlmsDecline,
+ sendTownTourNPCDialogClosed,
+ sendTownTourNPCInteractionOpened,
+ sendTownTourNPCInteractionClosed,
buildLootFromCombatEnd,
buildMerchantLootDrop,
} from './game/ws-handler';
@@ -32,7 +35,16 @@ import {
offlineReportHasActivity,
} from './network/api';
import type { HeroResponse, Achievement, ChangelogPayload } from './network/api';
-import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types';
+import type {
+ AdventureLogEntry,
+ Town,
+ HeroQuest,
+ NPC,
+ TownData,
+ EquipmentItem,
+ BuildingData,
+ TownTourPhasePayload,
+} from './game/types';
import type { OfflineReport as OfflineReportData } from './network/api';
import {
BUFF_COOLDOWN_MS,
@@ -223,6 +235,22 @@ function mapEquipment(
return out;
}
+function townTourPayloadToNPCData(p: TownTourPhasePayload, loc: Locale, town: Town | null): NPCData {
+ const tw = town ?? undefined;
+ const displayName = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName ?? '') : (p.npcName ?? '');
+ return {
+ id: p.npcId ?? 0,
+ name: displayName,
+ nameKey: p.npcNameKey,
+ type: (p.npcType ?? 'merchant') as NPCData['type'],
+ worldX: p.worldX ?? 0,
+ worldY: p.worldY ?? 0,
+ townId: p.townId,
+ townLevelMin: tw?.levelMin ?? 1,
+ townLevelMax: tw?.levelMax ?? 1,
+ };
+}
+
/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */
function townToTownData(
town: Town,
@@ -268,9 +296,14 @@ function heroResponseToState(res: HeroResponse): HeroState {
restKind: res.restKind,
excursionPhase: res.excursionPhase,
excursionKind:
- res.excursionKind === 'roadside' || res.excursionKind === 'adventure'
- ? res.excursionKind
+ res.excursionKind === 'roadside' ||
+ res.excursionKind === 'adventure' ||
+ res.excursionKind === 'town'
+ ? (res.excursionKind as HeroState['excursionKind'])
: undefined,
+ townTourPhase: res.townTourPhase,
+ townTourNpcId: res.townTourNpcId,
+ townTourExitPending: res.townTourExitPending,
attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense,
@@ -379,11 +412,26 @@ export function App() {
const [heroSheetOpen, setHeroSheetOpen] = useState(false);
const [heroSheetInitialTab, setHeroSheetInitialTab] = useState('stats');
- // NPC interaction state (server-driven via town_enter)
+ // NPC interaction (legacy / non-tour); town tour uses `townTourLastPayload` (NPCInteraction first; NPCDialog only after tap).
const [nearestNPC, setNearestNPC] = useState(null);
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState(null);
- /** Server signaled a town NPC visit; UI waits until the hero display reaches the NPC. */
- const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState(null);
+ const [townTourLastPayload, setTownTourLastPayload] = useState(null);
+
+ const townTourDialogWs = useMemo(
+ () => ({
+ onInteractionOpened: () => {
+ const w = wsRef.current;
+ if (w) sendTownTourNPCInteractionOpened(w);
+ },
+ onDialogAndInteractionClosed: () => {
+ const w = wsRef.current;
+ if (!w) return;
+ sendTownTourNPCDialogClosed(w);
+ sendTownTourNPCInteractionClosed(w);
+ },
+ }),
+ [],
+ );
// Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState(null);
@@ -894,38 +942,39 @@ export function App() {
setToast({ message: t(bundle.entering, { townName: townDisp }), color: '#daa520' });
appendLogClientMessage(formatClientLogLine(bundle, 'enteredTown', { town: townDisp }));
setNearestNPC(null);
- setNpcVisitAwaitingProximity(null);
setSelectedNPC(null);
setNpcInteractionDismissed(null);
+ setTownTourLastPayload(null);
},
onAdventureLogLine: (p) => {
appendLogPayload(p);
},
- onTownNPCVisit: (p) => {
- const loc = i18nForLogRef.current.locale;
- setNearestNPC(null);
- setNpcInteractionDismissed(null);
- const displayName = p.nameKey ? npcLabel(loc, p.nameKey, p.name) : p.name;
- const tw = townsRef.current.find((t) => t.id === p.townId);
- setNpcVisitAwaitingProximity({
- id: p.npcId,
- name: displayName,
- nameKey: p.nameKey,
- type: p.type as NPCData['type'],
- worldX: p.worldX ?? 0,
- worldY: p.worldY ?? 0,
- townId: p.townId,
- townLevelMin: tw?.levelMin ?? 1,
- townLevelMax: tw?.levelMax ?? 1,
- });
+ onTownNPCVisit: () => {
+ // Town NPC UI is driven by `town_tour_phase` (engine still gets lines via ws-handler).
+ },
+
+ onTownTourPhase: (p) => {
+ setTownTourLastPayload(p);
+ const ph = p.phase;
+ if (ph === 'npc_welcome' || ph === 'npc_service') {
+ setNpcInteractionDismissed(null);
+ }
+ if (ph === 'wander' || ph === 'rest' || ph === 'npc_approach') {
+ setSelectedNPC(null);
+ }
+ },
+
+ onTownTourServiceEnd: () => {
+ setTownTourLastPayload(null);
+ setSelectedNPC(null);
},
onTownExit: () => {
setCurrentTown(null);
setNearestNPC(null);
- setNpcVisitAwaitingProximity(null);
+ setTownTourLastPayload(null);
},
onNPCEncounter: (p) => {
@@ -1032,43 +1081,6 @@ export function App() {
};
}, []);
- // Open trader / quest / healer panel only after the hero sprite has reached the NPC (not on town_enter).
- useEffect(() => {
- if (!npcVisitAwaitingProximity) return;
- const pending = npcVisitAwaitingProximity;
- const proximityR = 0.55;
- const proximityR2 = proximityR * proximityR;
- const timeoutMs = 5000;
- const started = performance.now();
- let raf = 0;
-
- const step = () => {
- const eng = engineRef.current;
- let closeEnough = false;
- if (eng) {
- const { x, y } = eng.getHeroDisplayWorldPosition();
- const dx = x - pending.worldX;
- const dy = y - pending.worldY;
- closeEnough = dx * dx + dy * dy <= proximityR2;
- }
- if (closeEnough || performance.now() - started > timeoutMs) {
- const role =
- pending.type === 'merchant'
- ? tr.shopLabel
- : pending.type === 'healer'
- ? tr.healerLabel
- : tr.questLabel;
- setToast({ message: `${role}: ${pending.name}`, color: '#c9a227' });
- setNearestNPC(pending);
- setNpcVisitAwaitingProximity(null);
- return;
- }
- raf = requestAnimationFrame(step);
- };
- raf = requestAnimationFrame(step);
- return () => cancelAnimationFrame(raf);
- }, [npcVisitAwaitingProximity, tr]);
-
// Restore per-hero buff button cooldowns
useEffect(() => {
const id = gameState.hero?.id;
@@ -1353,11 +1365,16 @@ export function App() {
setNpcInteractionDismissed(npc.id);
}, []);
- const handleNPCInteractionDismiss = useCallback(() => {
- if (nearestNPC) {
- setNpcInteractionDismissed(nearestNPC.id);
+ const handleTownTourInteractionDismiss = useCallback(() => {
+ const w = wsRef.current;
+ if (!w) return;
+ const ph = townTourLastPayload?.phase;
+ if (ph === 'npc_welcome') {
+ sendTownTourNPCDialogClosed(w);
+ } else if (ph === 'npc_service') {
+ sendTownTourNPCInteractionClosed(w);
}
- }, [nearestNPC]);
+ }, [townTourLastPayload?.phase]);
// ---- Wandering NPC Encounter Handlers (via WS) ----
@@ -1379,13 +1396,33 @@ export function App() {
appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'declinedWanderingMerchant'));
}, [appendLogClientMessage]);
- // Show NPC interaction when near an NPC and not dismissed
+ const heroOnTownTour = gameState.hero?.excursionKind === 'town';
+
+ const townTourChipActive =
+ (townTourLastPayload?.phase === 'npc_welcome' || townTourLastPayload?.phase === 'npc_service') &&
+ (townTourLastPayload.npcId ?? 0) > 0;
+
+ const townTourInteractionNPC =
+ townTourChipActive && townTourLastPayload
+ ? townTourPayloadToNPCData(townTourLastPayload, locale, currentTown)
+ : null;
+
+ const legacyProximityNPC = !heroOnTownTour ? nearestNPC : null;
+
+ const interactionNpc = townTourInteractionNPC ?? legacyProximityNPC;
+
const showNPCInteraction =
- nearestNPC !== null &&
- npcInteractionDismissed !== nearestNPC.id &&
+ interactionNpc != null &&
+ npcInteractionDismissed !== interactionNpc.id &&
(gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) &&
!selectedNPC;
+ const dialogNpc = selectedNPC;
+
+ const handleNPCInteractionDismiss = useCallback(() => {
+ if (interactionNpc) setNpcInteractionDismissed(interactionNpc.id);
+ }, [interactionNpc]);
+
const completedQuestCount = useMemo(
() =>
heroQuests.filter((q) => q.status === 'completed').length,
@@ -1497,20 +1534,21 @@ export function App() {
)}
- {/* NPC Proximity Interaction */}
- {showNPCInteraction && nearestNPC && (
+ {/* Town tour service chip or legacy proximity NPC */}
+ {showNPCInteraction && interactionNpc && (
)}
- {/* NPC Dialog */}
- {selectedNPC && (
+ {/* NPC Dialog: opened from interaction / sheet only (not auto on town tour approach). */}
+ {dialogNpc && (
setToast({ message, color })}
questClaimDisabled={questClaimDisabled}
+ townTourWs={heroOnTownTour && selectedNPC ? townTourDialogWs : undefined}
/>
)}
diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts
index fb4c9dc..c39d758 100644
--- a/frontend/src/game/types.ts
+++ b/frontend/src/game/types.ts
@@ -138,8 +138,12 @@ export interface HeroState {
restKind?: string;
/** Mini-adventure leg: "out" | "wild" | "return" when excursion active */
excursionPhase?: string;
- /** Attractor excursion mode from server: roadside rest vs walking adventure */
- excursionKind?: 'roadside' | 'adventure';
+ /** Attractor excursion mode from server: roadside rest vs walking adventure vs in-town tour */
+ excursionKind?: 'roadside' | 'adventure' | 'town';
+ /** Sub-phase during `excursionKind: town` (server-driven NPC UI). */
+ townTourPhase?: string;
+ townTourNpcId?: number;
+ townTourExitPending?: boolean;
attackSpeed: number;
damage: number;
defense: number;
@@ -461,6 +465,8 @@ export type ServerMessageType =
| 'town_enter'
| 'town_exit'
| 'town_npc_visit'
+ | 'town_tour_phase'
+ | 'town_tour_service_end'
| 'npc_encounter'
| 'npc_encounter_end'
| 'level_up'
@@ -604,6 +610,24 @@ export interface TownNPCVisitPayload {
worldY: number;
}
+/** Server town tour FSM phase + NPC context (`town_tour_phase` WS). */
+export interface TownTourPhasePayload {
+ phase: string;
+ townId: number;
+ townNameKey?: string;
+ npcId?: number;
+ npcName?: string;
+ npcNameKey?: string;
+ npcType?: string;
+ worldX?: number;
+ worldY?: number;
+ exitPending?: boolean;
+}
+
+export interface TownTourServiceEndPayload {
+ reason: string;
+}
+
/** Server-persisted adventure log line (legacy uses message; new rows use event). */
export interface AdventureLogLinePayload {
message?: string;
diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts
index b4a425f..8040302 100644
--- a/frontend/src/game/ws-handler.ts
+++ b/frontend/src/game/ws-handler.ts
@@ -13,6 +13,8 @@ import type {
BuffAppliedPayload,
TownEnterPayload,
TownNPCVisitPayload,
+ TownTourPhasePayload,
+ TownTourServiceEndPayload,
AdventureLogLinePayload,
NPCEncounterPayload,
NPCEncounterEndPayload,
@@ -43,6 +45,8 @@ export interface WSHandlerCallbacks {
onBuffApplied?: (payload: BuffAppliedPayload) => void;
onTownEnter?: (payload: TownEnterPayload) => void;
onTownNPCVisit?: (payload: TownNPCVisitPayload) => void;
+ onTownTourPhase?: (payload: TownTourPhasePayload) => void;
+ onTownTourServiceEnd?: (payload: TownTourServiceEndPayload) => void;
onAdventureLogLine?: (payload: AdventureLogLinePayload) => void;
onTownExit?: () => void;
onNPCEncounter?: (payload: NPCEncounterPayload) => void;
@@ -200,6 +204,16 @@ export function wireWSHandler(
callbacks.onTownNPCVisit?.(p);
});
+ ws.on('town_tour_phase', (msg: ServerMessage) => {
+ const p = msg.payload as TownTourPhasePayload;
+ callbacks.onTownTourPhase?.(p);
+ });
+
+ ws.on('town_tour_service_end', (msg: ServerMessage) => {
+ const p = msg.payload as TownTourServiceEndPayload;
+ callbacks.onTownTourServiceEnd?.(p);
+ });
+
ws.on('adventure_log_line', (msg: ServerMessage) => {
const p = msg.payload as AdventureLogLinePayload;
callbacks.onAdventureLogLine?.(p);
@@ -294,6 +308,18 @@ export function sendNPCAlmsDecline(ws: GameWebSocket): void {
ws.send('npc_alms_decline', {});
}
+export function sendTownTourNPCDialogClosed(ws: GameWebSocket): void {
+ ws.send('town_tour_npc_dialog_closed', {});
+}
+
+export function sendTownTourNPCInteractionOpened(ws: GameWebSocket): void {
+ ws.send('town_tour_npc_interaction_opened', {});
+}
+
+export function sendTownTourNPCInteractionClosed(ws: GameWebSocket): void {
+ ws.send('town_tour_npc_interaction_closed', {});
+}
+
/**
* Build a LootDrop from combat_end payload for the loot popup UI.
*/
diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts
index 0a09dae..bc28190 100644
--- a/frontend/src/network/api.ts
+++ b/frontend/src/network/api.ts
@@ -110,6 +110,9 @@ export interface HeroResponse {
restKind?: string;
excursionPhase?: string;
excursionKind?: string;
+ townTourPhase?: string;
+ townTourNpcId?: number;
+ townTourExitPending?: boolean;
/** Removed from server; gear.main_hand / legacy weapon only */
weaponId?: number;
armorId?: number;
@@ -684,18 +687,10 @@ export interface MerchantStockResponse {
items: MerchantGearOfferItem[];
}
-/** Freeze town NPC visit timers while quest/shop UI is open (POST /hero/npc-dialog-pause). */
-export async function setNPCDialogPause(
- open: boolean,
- telegramId?: number,
- opts?: { advanceTownVisit?: boolean },
-): Promise {
+/** Freeze town tour welcome/service timers while NPCDialog is open (POST /hero/npc-dialog-pause). */
+export async function setNPCDialogPause(open: boolean, telegramId?: number): Promise {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
- const body: { open: boolean; advanceTownVisit?: boolean } = { open };
- if (!open && opts?.advanceTownVisit) {
- body.advanceTownVisit = true;
- }
- await apiPost<{ ok: boolean }>(`/hero/npc-dialog-pause${query}`, body);
+ await apiPost<{ ok: boolean }>(`/hero/npc-dialog-pause${query}`, { open });
}
/** Roll merchant stock for this town tier (POST /hero/npc-merchant-stock). */
diff --git a/frontend/src/ui/NPCDialog.tsx b/frontend/src/ui/NPCDialog.tsx
index d6c2c03..0cdea0f 100644
--- a/frontend/src/ui/NPCDialog.tsx
+++ b/frontend/src/ui/NPCDialog.tsx
@@ -34,6 +34,14 @@ interface NPCDialogProps {
onToast: (message: string, color: string) => void;
/** Block quest reward claim while hero is dead. */
questClaimDisabled?: boolean;
+ /**
+ * When set, notifies the server for ExcursionKindTown: interaction opened on mount,
+ * dialog + interaction closed on unmount (WebSocket commands).
+ */
+ townTourWs?: {
+ onInteractionOpened: () => void;
+ onDialogAndInteractionClosed: () => void;
+ };
}
// ---- Styles ----
@@ -290,6 +298,7 @@ export function NPCDialog({
onHeroUpdated,
onToast,
questClaimDisabled = false,
+ townTourWs,
}: NPCDialogProps) {
const tr = useT();
const { locale } = useLocale();
@@ -302,19 +311,21 @@ export function NPCDialog({
const telegramId = getTelegramUserId() ?? 1;
- // Pause town NPC visit timers while any NPC dialog (shop / quests / healer) is open.
+ // Pause town tour timers while any NPC dialog (shop / quests / healer) is open.
useEffect(() => {
let cancelled = false;
setNPCDialogPause(true, telegramId).catch((err) => {
if (!cancelled) console.warn('[NPCDialog] npc-dialog-pause (open) failed:', err);
});
+ townTourWs?.onInteractionOpened();
return () => {
cancelled = true;
- setNPCDialogPause(false, telegramId, { advanceTownVisit: true }).catch((err) => {
+ setNPCDialogPause(false, telegramId).catch((err) => {
console.warn('[NPCDialog] npc-dialog-pause (close) failed:', err);
});
+ townTourWs?.onDialogAndInteractionClosed();
};
- }, [telegramId]);
+ }, [telegramId, townTourWs]);
// Merchant: roll stock when the shop opens (server-tier gear for this town).
useEffect(() => {
diff --git a/frontend/src/ui/NPCInteraction.tsx b/frontend/src/ui/NPCInteraction.tsx
index 13d8578..9ee7884 100644
--- a/frontend/src/ui/NPCInteraction.tsx
+++ b/frontend/src/ui/NPCInteraction.tsx
@@ -10,6 +10,8 @@ interface NPCInteractionProps {
/** Open shop (merchant) or services (healer) dialog. */
onOpenServiceDialog: (npc: NPCData) => void;
onDismiss: () => void;
+ /** When set, called before onDismiss for town tour (server interaction_closed). */
+ onDismissTownTour?: () => void;
}
// ---- Styles ----
@@ -102,6 +104,7 @@ export function NPCInteraction({
onViewQuests,
onOpenServiceDialog,
onDismiss,
+ onDismissTownTour,
}: NPCInteractionProps) {
const tr = useT();
const info = npcColor(npc.type, tr);
@@ -157,7 +160,10 @@ export function NPCInteraction({
{info.text}