town tour

master
Denis Ranneft 1 month ago
parent 0fde5c7f26
commit 3d0b050cce

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

@ -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(`<div class="kv"><kbd>currentTownId</kbd><div>${e(h.currentTownId)}</div></div>`);
if (h.destinationTownId != null) rows.push(`<div class="kv"><kbd>destinationTownId</kbd><div>${e(h.destinationTownId)}</div></div>`);
if (h.restKind) rows.push(`<div class="kv"><kbd>restKind</kbd><div>${e(h.restKind)}</div></div>`);
if (h.excursionKind) {
rows.push(`<div class="kv"><kbd>excursionKind (hero)</kbd><div>${e(h.excursionKind)}</div></div>`);
}
if (live && live.online) {
if (live.excursionKind) rows.push(`<div class="kv"><kbd>excursionKind (live)</kbd><div>${e(live.excursionKind)}</div></div>`);
if (live.excursionPhase) rows.push(`<div class="kv"><kbd>excursionPhase</kbd><div>${e(live.excursionPhase)}</div></div>`);
if (live.restUntil) rows.push(`<div class="kv"><kbd>отдых / restUntil</kbd><div>${statusCountdownLine(live.restUntil)}</div></div>`);
if (live.townLeaveAt) rows.push(`<div class="kv"><kbd>в городе до выхода</kbd><div>${statusCountdownLine(live.townLeaveAt)}</div></div>`);
if (live.nextTownNPCRollAt) rows.push(`<div class="kv"><kbd>след. событие NPC в городе</kbd><div>${statusCountdownLine(live.nextTownNPCRollAt)}</div></div>`);
if (live.wanderingMerchantDeadline) {
rows.push(`<div class="kv"><kbd>окно бродячего торговца</kbd><div>${statusCountdownLine(live.wanderingMerchantDeadline)}</div></div>`);
}
const tt = live.townTour;
if (tt && typeof tt === "object") {
rows.push(`<div style="grid-column:1/-1;margin-top:8px;padding-top:8px;border-top:1px solid #2a3551;font-weight:600;color:#cfe3ff">Экскурсия по городу (town tour)</div>`);
if (tt.phase) rows.push(`<div class="kv"><kbd>phase</kbd><div>${e(tt.phase)}</div></div>`);
if (tt.npcId) rows.push(`<div class="kv"><kbd>npcId</kbd><div>${e(tt.npcId)}</div></div>`);
if (tt.townTourEndsAt) rows.push(`<div class="kv"><kbd>конец пребывания в городе</kbd><div>${statusCountdownLine(tt.townTourEndsAt)}</div></div>`);
if (tt.wanderNextAt) rows.push(`<div class="kv"><kbd>след. смена аттрактора</kbd><div>${statusCountdownLine(tt.wanderNextAt)}</div></div>`);
if (tt.townWelcomeUntil) rows.push(`<div class="kv"><kbd>welcome до</kbd><div>${statusCountdownLine(tt.townWelcomeUntil)}</div></div>`);
if (tt.townServiceUntil) rows.push(`<div class="kv"><kbd>service до</kbd><div>${statusCountdownLine(tt.townServiceUntil)}</div></div>`);
if (tt.townRestUntil) rows.push(`<div class="kv"><kbd>отдых в туре</kbd><div>${statusCountdownLine(tt.townRestUntil)}</div></div>`);
if (tt.townExitPending) rows.push(`<div class="kv"><kbd>выход из города</kbd><div>ожидает безопасной фазы</div></div>`);
if (tt.townTourDialogOpen) rows.push(`<div class="kv"><kbd>UI</kbd><div>NPCDialog открыт (таймеры сдвигаются)</div></div>`);
if (tt.townTourInteractionOpen) rows.push(`<div class="kv"><kbd>UI</kbd><div>interaction открыт</div></div>`);
if (tt.townTourStandX != null && tt.townTourStandY != null) {
rows.push(`<div class="kv"><kbd>stand</kbd><div>${e(tt.townTourStandX)}, ${e(tt.townTourStandY)}</div></div>`);
}
}
}
if (tp) {
if (tp.restUntil) rows.push(`<div class="kv"><kbd>отдых (из БД)</kbd><div>${e(tp.restKind || "")}: ${statusCountdownLine(tp.restUntil)}</div></div>`);
@ -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 = `<option value="">— town —</option>`;
let townTourApproachPanel = "";
if (state.selectedHeroId) {
const rowsDbH = state.contentGearRows || [];
const rowsCatH = state.gearCatalog || [];
@ -2049,6 +2114,22 @@
const hgSubOpts = `<option value="">All subtypes</option>` + hgSubs.map(s => `<option value="${e(s)}" ${state.heroGrantFilterSubtype === s ? "selected" : ""}>${e(s)}</option>`).join("");
const hgRarOpts = `<option value="">All rarities</option>` + hgRars.map(s => `<option value="${e(s)}" ${state.heroGrantFilterRarity === s ? "selected" : ""}>${e(s)}</option>`).join("");
teleportOpts = `<option value="">— town —</option>` + (state.teleportTowns || []).map(t => `<option value="${t.id}">${e(t.name)} (#${t.id})</option>`).join("");
const liveMov = h.adminLiveMovement;
const showTownTourAdmin = h.excursionKind === "town" && liveMov && liveMov.online;
const npcListApproach = state.townTourApproachNpcs || [];
const npcOptsApproach = npcListApproach.map(n =>
`<option value="${e(String(n.id))}">#${e(n.id)} ${e(n.name || "")} (${e(n.type || "")})</option>`
).join("");
townTourApproachPanel = showTownTourAdmin ? `
<div style="margin-top:12px;padding-top:12px;border-top:1px solid #2a3551">
<h4 style="margin:0 0 8px;font-size:14px;color:#cfe3ff">Экскурсия по городу</h4>
<p class="muted" style="margin:0 0 8px;font-size:12px">Только для онлайн-героя с <kbd>excursionKind=town</kbd>. Текущий город: <kbd>${e(h.currentTownId)}</kbd>. Детали — блок «Путь, город, отдых».</p>
<button type="button" class="btn" onclick="withAction(loadTownTourApproachNpcs)">Загрузить NPC текущего города</button>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<select id="town-tour-approach-npc-select" style="min-width:200px;max-width:340px"><option value="">— NPC —</option>${npcOptsApproach}</select>
<button type="button" class="btn" onclick="withAction(townTourApproachSelectedNpc)">Подойти к NPC</button>
</div>
</div>` : "";
const equipped = state.gear?.equipped || {};
const inventory = state.gear?.inventory || [];
const slotRows = Object.keys(equipped).sort().map(slot => `
@ -2214,6 +2295,7 @@
<div><label class="muted">&nbsp;</label><br /><button type="button" class="btn" onclick="withAction(teleportHeroToTown)">Teleport</button></div>
</div>
<p class="muted" style="margin-top:6px;margin-bottom:0">Города из графа (<kbd>GET /admin/towns</kbd>). Герой жив и не в бою.</p>
${townTourApproachPanel}
</aside>
</div>
<div style="margin-top:14px;padding-top:12px;border-top:1px solid #2a3551">

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

@ -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,163 +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()
if hm.Excursion.Kind == model.ExcursionKindTown {
processTownTourMovement(heroID, hm, graph, now, sender, adventureLog, townTourOffline)
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()
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 {
// Legacy in-town row without town excursion: force exit.
if graph != nil {
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
@ -2241,142 +2099,8 @@ func ProcessSingleHeroMovementTick(
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
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
}
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()
case model.StateWalking:
cfg := tuning.Get()

@ -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,54 +281,77 @@ 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
}
hqs, err := s.questStore.ListHeroQuests(ctx, heroID)
if err != nil {
s.logger.Warn("offline town tour: list hero quests", "error", err)
return false
}
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 {
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)
}
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},
Code: model.LogPhraseQuestAccepted,
Args: map[string]any{"questKey": qk},
},
})
}
return ok
}
return false
}
if tryQuest() {
return
}
if npc.Type == "merchant" {
gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance {
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 := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now)
drop, err := ApplyPreparedTownMerchantPurchase(ctx, s.gearStore, h, items[0], now)
if err != nil {
h.Gold += gearCost
s.logger.Warn("offline town merchant gear", "hero_id", heroID, "error", err)
s.logger.Warn("offline town tour merchant gear", "hero_id", heroID, "error", err)
} else if al != nil && drop != nil {
townKey := ""
if town != nil {
@ -344,11 +367,16 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
},
})
}
return
}
}
}
case "healer":
_, healCost := tuning.EffectiveNPCShopCosts()
potionCost, _ := tuning.EffectiveNPCShopCosts()
if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance {
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,44 +400,28 @@ 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 npc.Type == "merchant" {
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
}
if len(candidates) == 0 {
if al != nil {
soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
if soldItems > 0 && al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked,
Args: map[string]any{"npcKey": npc.NameKey},
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
}
return true
return
}
if rand.Float64() >= offlineServiceChance {
if al != nil {
if npc.Type == "quest_giver" && al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked,
@ -416,30 +429,6 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
},
})
}
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 {
qk := pick.QuestKey
if qk == "" {
qk = fmt.Sprintf("quest.%d", pick.ID)
}
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestAccepted,
Args: map[string]any{"questKey": qk},
},
})
}
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.

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

@ -69,6 +69,22 @@ 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"`
@ -79,9 +95,11 @@ type adminLiveMovementJSON struct {
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) {

@ -885,7 +885,6 @@ func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) {
}
var req struct {
Open bool `json:"open"`
AdvanceTownVisit bool `json:"advanceTownVisit"`
}
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.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})

@ -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"
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
@ -49,14 +62,40 @@ type ExcursionSession struct {
// 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
}
@ -80,4 +119,16 @@ type ExcursionPersisted struct {
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"`
}

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

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

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

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

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

@ -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 &lt; 50%, else potion, else merchant autosell / quest-giver checked log).
### Step-by-Step Changes
#### 1. Fix WS protocol — `internal/handler/ws.go`

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

@ -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<HeroSheetTab>('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<NPCData | null>(null);
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null);
/** Server signaled a town NPC visit; UI waits until the hero display reaches the NPC. */
const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState<NPCData | null>(null);
const [townTourLastPayload, setTownTourLastPayload] = useState<TownTourPhasePayload | null>(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<NPCEncounterEvent | null>(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);
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);
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,
});
}
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() {
</div>
)}
{/* NPC Proximity Interaction */}
{showNPCInteraction && nearestNPC && (
{/* Town tour service chip or legacy proximity NPC */}
{showNPCInteraction && interactionNpc && (
<NPCInteraction
npc={nearestNPC}
npc={interactionNpc}
onViewQuests={handleNPCViewQuests}
onOpenServiceDialog={handleNPCViewQuests}
onDismiss={handleNPCInteractionDismiss}
onDismissTownTour={townTourInteractionNPC ? handleTownTourInteractionDismiss : undefined}
/>
)}
{/* NPC Dialog */}
{selectedNPC && (
{/* NPC Dialog: opened from interaction / sheet only (not auto on town tour approach). */}
{dialogNpc && (
<NPCDialog
npc={selectedNPC}
npc={dialogNpc}
heroQuests={heroQuests}
heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
@ -1521,6 +1559,7 @@ export function App() {
onHeroUpdated={handleNPCHeroUpdated}
onToast={(message, color) => setToast({ message, color })}
questClaimDisabled={questClaimDisabled}
townTourWs={heroOnTownTour && selectedNPC ? townTourDialogWs : undefined}
/>
)}

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

@ -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.
*/

@ -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<void> {
/** Freeze town tour welcome/service timers while NPCDialog is open (POST /hero/npc-dialog-pause). */
export async function setNPCDialogPause(open: boolean, telegramId?: number): Promise<void> {
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). */

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

@ -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({
<div style={npcTypeStyle}>{info.text}</div>
</div>
<button
onClick={onDismiss}
onClick={() => {
onDismissTownTour?.();
onDismiss();
}}
style={{
background: 'none',
border: 'none',

Loading…
Cancel
Save