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 | | `level_up` | `newLevel`, all stat fields |
| `buff_applied` | `buffType`, `magnitude`, `durationMs`, `expiresAt` | | `buff_applied` | `buffType`, `magnitude`, `durationMs`, `expiresAt` |
| `debuff_applied` | `debuffType`, `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 ### Client → Server Commands
@ -39,6 +42,9 @@ All messages (both directions) use `{"type": string, "payload": object}`. Text `
| `request_encounter` | `{}` | | `request_encounter` | `{}` |
| `request_revive` | `{}` | | `request_revive` | `{}` |
| `activate_buff` | `{"buffType": "rage"}` | | `activate_buff` | `{"buffType": "rage"}` |
| `town_tour_npc_interaction_opened` | `{}` |
| `town_tour_npc_dialog_closed` | `{}` |
| `town_tour_npc_interaction_closed` | `{}` |
## Critical Gaps (P0 — must fix) ## Critical Gaps (P0 — must fix)

@ -197,6 +197,9 @@
gearFilterSubtype: "", gearFilterSubtype: "",
grantGearSearchQuery: "", grantGearSearchQuery: "",
heroGrantGearCandidates: [], heroGrantGearCandidates: [],
/** NPC list for «Подойти к NPC» (town tour admin); from GET quests/towns/{id}/npcs */
townTourApproachNpcs: [],
townTourApproachNpcTownId: null,
heroGrantFilterSlot: "", heroGrantFilterSlot: "",
heroGrantFilterRarity: "", heroGrantFilterRarity: "",
heroGrantFilterSubtype: "", 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.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.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.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 && 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.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.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.nextTownNPCRollAt) rows.push(`<div class="kv"><kbd>след. событие NPC в городе</kbd><div>${statusCountdownLine(live.nextTownNPCRollAt)}</div></div>`);
if (live.wanderingMerchantDeadline) { if (live.wanderingMerchantDeadline) {
rows.push(`<div class="kv"><kbd>окно бродячего торговца</kbd><div>${statusCountdownLine(live.wanderingMerchantDeadline)}</div></div>`); 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) {
if (tp.restUntil) rows.push(`<div class="kv"><kbd>отдых (из БД)</kbd><div>${e(tp.restKind || "")}: ${statusCountdownLine(tp.restUntil)}</div></div>`); 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; state.selectedHeroId = heroId;
const [hero, gear, quests] = await Promise.all([api(`heroes/${heroId}`), api(`heroes/${heroId}/gear`), api(`heroes/${heroId}/quests`)]); 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; 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(); 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) { async function heroAction(action, body = {}, pollMovement = false) {
if (!state.selectedHeroId) return; if (!state.selectedHeroId) return;
await api(`heroes/${state.selectedHeroId}/${action}`, { method: "POST", body: JSON.stringify(body) }); await api(`heroes/${state.selectedHeroId}/${action}`, { method: "POST", body: JSON.stringify(body) });
@ -2031,6 +2095,7 @@
let heroExtra = ""; let heroExtra = "";
let teleportOpts = `<option value="">— town —</option>`; let teleportOpts = `<option value="">— town —</option>`;
let townTourApproachPanel = "";
if (state.selectedHeroId) { if (state.selectedHeroId) {
const rowsDbH = state.contentGearRows || []; const rowsDbH = state.contentGearRows || [];
const rowsCatH = state.gearCatalog || []; 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 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(""); 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(""); 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 equipped = state.gear?.equipped || {};
const inventory = state.gear?.inventory || []; const inventory = state.gear?.inventory || [];
const slotRows = Object.keys(equipped).sort().map(slot => ` 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><label class="muted">&nbsp;</label><br /><button type="button" class="btn" onclick="withAction(teleportHeroToTown)">Teleport</button></div>
</div> </div>
<p class="muted" style="margin-top:6px;margin-bottom:0">Города из графа (<kbd>GET /admin/towns</kbd>). Герой жив и не в бою.</p> <p class="muted" style="margin-top:6px;margin-bottom:0">Города из графа (<kbd>GET /admin/towns</kbd>). Герой жив и не в бою.</p>
${townTourApproachPanel}
</aside> </aside>
</div> </div>
<div style="margin-top:14px;padding-top:12px;border-top:1px solid #2a3551"> <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) e.handleNPCAlmsAccept(msg)
case "npc_alms_decline": case "npc_alms_decline":
e.handleNPCAlmsDecline(msg) 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: default:
// Commands like accept_quest, claim_quest, npc_interact etc. // Commands like accept_quest, claim_quest, npc_interact etc.
// are handled by their respective REST handlers for now. // 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 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. // ApplyAdminStartRest puts an online hero into town-style rest at the current location.
func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) { func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) {
e.mu.Lock() 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) { func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
if e == nil { if e == nil {
return return
@ -1998,11 +2024,14 @@ func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
if hm == nil { if hm == nil {
return return
} }
if hm.Excursion.Kind == model.ExcursionKindTown {
hm.townTourSetDialogOpen(locked)
return
}
hm.TownNPCUILock = locked hm.TownNPCUILock = locked
} }
// SkipTownNPCNarrationAfterDialog ends the current town NPC visit narration immediately when // SkipTownNPCNarrationAfterDialog applies town tour dialog-closed semantics (legacy name for REST).
// the client closes shop / healer / quest UI (next tick proceeds to the next NPC or plaza).
func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) { func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
if e == nil { if e == nil {
return return
@ -2010,10 +2039,49 @@ func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
hm := e.movements[heroID] hm := e.movements[heroID]
if hm == nil { if hm == nil || e.roadGraph == nil {
return 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). // 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 // lastTownPausePersistSignature tracks the last persisted excursion/rest snapshot so we can
// persist only on meaningful changes (start/end/phase change). // persist only on meaningful changes (start/end/phase change).
lastTownPausePersistSignature townPausePersistSignature lastTownPausePersistSignature townPausePersistSignature
// sentTownTourWireSig avoids spamming town_tour_phase when nothing changed.
sentTownTourWireSig string
} }
// townPausePersistSignature captures the excursion/rest fields that should trigger persistence. // 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.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
hm.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil) hm.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil)
hm.TownLeaveAt = shift(hm.TownLeaveAt) 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.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt) hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil) hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil)
@ -744,6 +755,9 @@ func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
if !hm.Excursion.Active() { if !hm.Excursion.Active() {
return false return false
} }
if hm.Excursion.Kind == model.ExcursionKindTown {
return false
}
if hm.State == model.StateFighting { if hm.State == model.StateFighting {
return false return false
} }
@ -870,7 +884,7 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
} }
func (hm *HeroMovement) excursionUsesAttractors() bool { 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 { func excursionArrivalEpsilon() float64 {
@ -1123,28 +1137,20 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (mons
return false, model.Enemy{}, true 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). // are NPCs, otherwise a short resting state (StateResting).
func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
destID := hm.DestinationTownID destID := hm.DestinationTownID
hm.CurrentTownID = destID hm.CurrentTownID = destID
hm.DestinationTownID = 0 hm.DestinationTownID = 0
hm.Road = nil hm.Road = nil
hm.TownNPCQueue = nil clearLegacyTownNPCState(hm)
hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.TownRestHealRemainder = 0 hm.TownRestHealRemainder = 0
hm.Excursion = model.ExcursionSession{} hm.Excursion = model.ExcursionSession{}
hm.sentTownTourWireSig = ""
hm.ActiveRestKind = model.RestKindNone hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0 hm.RestHealRemainder = 0
hm.clearNPCWalk()
hm.clearTownCenterWalk() hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false
ids := graph.TownNPCIDs(destID) ids := graph.TownNPCIDs(destID)
if len(ids) == 0 { if len(ids) == 0 {
@ -1155,31 +1161,20 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
return 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.State = model.StateInTown
hm.Hero.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. // LeaveTown transitions the hero from town to walking, picking a new destination.
func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownNPCQueue = nil clearLegacyTownNPCState(hm)
hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.TownRestHealRemainder = 0 hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{} hm.RestUntil = time.Time{}
hm.ActiveRestKind = model.RestKindNone hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0 hm.RestHealRemainder = 0
hm.Excursion = model.ExcursionSession{} hm.Excursion = model.ExcursionSession{}
hm.clearNPCWalk() hm.sentTownTourWireSig = ""
hm.clearTownCenterWalk() hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false hm.TownPlazaHealActive = false
hm.State = model.StateWalking hm.State = model.StateWalking
@ -1329,9 +1324,19 @@ func (hm *HeroMovement) SyncToHero() {
} }
hm.Hero.ExcursionPhase = model.ExcursionNone hm.Hero.ExcursionPhase = model.ExcursionNone
hm.Hero.ExcursionKind = model.ExcursionKindNone hm.Hero.ExcursionKind = model.ExcursionKindNone
hm.Hero.TownTourPhase = ""
hm.Hero.TownTourNpcID = 0
hm.Hero.TownTourExitPending = false
if hm.Excursion.Active() { if hm.Excursion.Active() {
hm.Hero.ExcursionPhase = hm.Excursion.Phase
hm.Hero.ExcursionKind = hm.Excursion.Kind 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() hm.Hero.TownPause = hm.townPauseBlob()
} }
@ -1516,6 +1521,31 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
t := s.WanderNextAt t := s.WanderNextAt
ep.WanderNextAt = &t 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 return ep
} }
@ -1609,6 +1639,27 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
if ep.WanderNextAt != nil { if ep.WanderNextAt != nil {
hm.Excursion.WanderNextAt = *ep.WanderNextAt 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). // 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). // AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB).
type AfterTownEnterPersist func(hero *model.Hero) 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) { func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
if log == nil || hm.TownVisitStartedAt.IsZero() || hm.TownNPCUILock { if log == nil || hm.TownVisitStartedAt.IsZero() || hm.TownNPCUILock {
return 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 --- // --- Excursion (mini-adventure) FSM helpers ---
func smoothstep(t float64) float64 { 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. // 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). // 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. // 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( func ProcessSingleHeroMovementTick(
heroID int64, heroID int64,
hm *HeroMovement, hm *HeroMovement,
@ -1965,7 +1974,7 @@ func ProcessSingleHeroMovementTick(
onMerchantEncounter MerchantEncounterHook, onMerchantEncounter MerchantEncounterHook,
adventureLog AdventureLogWriter, adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist, persistAfterTownEnter AfterTownEnterPersist,
townNPCOfflineInteract TownNPCOfflineInteractHook, townTourOffline TownTourOfflineAtNPC,
) { ) {
if graph == nil { if graph == nil {
return return
@ -2076,242 +2085,12 @@ func ProcessSingleHeroMovementTick(
} }
case model.StateInTown: case model.StateInTown:
cfg := tuning.Get() if hm.Excursion.Kind == model.ExcursionKindTown {
dtTown := now.Sub(hm.LastMoveTick).Seconds() processTownTourMovement(heroID, hm, graph, now, sender, adventureLog, townTourOffline)
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()
return return
} }
// Legacy in-town row without town excursion: force exit.
// NPC visit pause ended: clear visit log state before the next roll. if graph != nil {
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
hm.LeaveTown(graph, now) hm.LeaveTown(graph, now)
hm.SyncToHero() hm.SyncToHero()
if sender != nil { if sender != nil {
@ -2320,63 +2099,8 @@ func ProcessSingleHeroMovementTick(
sender.SendToHero(heroID, "route_assigned", route) sender.SendToHero(heroID, "route_assigned", route)
} }
} }
return
} }
if now.Before(hm.NextTownNPCRollAt) { return
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: case model.StateWalking:
cfg := tuning.Get() cfg := tuning.Get()

@ -206,7 +206,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
const maxOfflineMovementSteps = 200000 const maxOfflineMovementSteps = 200000
step := 0 step := 0
offlineNPC := s.offlineTownNPCInteractHook(ctx) offlineTownTour := s.offlineTownTourAtNPC(ctx)
for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps { for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps {
step++ step++
next := hm.LastMoveTick.Add(movementTickRate()) 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) { adventureLog := func(heroID int64, line model.AdventureLogLine) {
s.addLog(ctx, heroID, line) 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 { if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break break
} }
@ -252,9 +252,9 @@ func (s *OfflineSimulator) SimulateHeroAt(ctx context.Context, hero *model.Hero,
return s.simulateHeroTick(ctx, hero, now, persist) return s.simulateHeroTick(ctx, hero, now, persist)
} }
func (s *OfflineSimulator) offlineTownNPCInteractHook(ctx context.Context) TownNPCOfflineInteractHook { func (s *OfflineSimulator) offlineTownTourAtNPC(ctx context.Context) TownTourOfflineAtNPC {
return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
return s.applyOfflineTownNPCVisit(ctx, heroID, hm, graph, npc, now, al) 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). // applyOfflineTownTourNPCVisit resolves one town-tour NPC stop without UI: quest → merchant upgrade → healer heal → potion → fallbacks.
// With no live WebSocket, service use (gear, potion, heal, quest accept) each fires independently with probability 0.2 when affordable. func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
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
}
h := hm.Hero h := hm.Hero
if h == nil { if h == nil {
return false return
} }
var town *model.Town var town *model.Town
if graph != nil { if graph != nil {
town = graph.Towns[hm.CurrentTownID] town = graph.Towns[hm.CurrentTownID]
} }
townLv := TownEffectiveLevel(town) townLv := TownEffectiveLevel(town)
const offlineServiceChance = 0.2 cfg := tuning.Get()
switch npc.Type { tryQuest := func() bool {
case "merchant": if npc.Type != "quest_giver" || s.questStore == nil {
share := cfg.MerchantTownAutoSellShare return false
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
} }
soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil) hqs, err := s.questStore.ListHeroQuests(ctx, heroID)
if soldItems > 0 && al != nil { if err != nil {
al(heroID, model.AdventureLogLine{ s.logger.Warn("offline town tour: list hero quests", "error", err)
Event: &model.AdventureLogEvent{ return false
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
} }
gearCost := tuning.EffectiveTownMerchantGearCost(townLv) taken := make(map[int64]struct{}, len(hqs))
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance { for _, hq := range hqs {
h.Gold -= gearCost taken[hq.QuestID] = struct{}{}
drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now) }
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 { if err != nil {
h.Gold += gearCost s.logger.Warn("offline town tour: try accept quest", "error", err)
s.logger.Warn("offline town merchant gear", "hero_id", heroID, "error", err) return false
} else if al != nil && drop != nil { }
townKey := "" if ok && al != nil {
if town != nil { qk := q.QuestKey
townKey = town.NameKey if qk == "" {
qk = fmt.Sprintf("quest.%d", q.ID)
} }
al(heroID, model.AdventureLogLine{ al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{ Event: &model.AdventureLogEvent{
Code: model.LogPhraseBoughtGearTownMerchant, Code: model.LogPhraseQuestAccepted,
Args: map[string]any{ Args: map[string]any{"questKey": qk},
"npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
"rarity": string(drop.Rarity), "itemId": drop.ItemID,
},
}, },
}) })
} }
return ok
} }
case "healer": return false
_, healCost := tuning.EffectiveNPCShopCosts() }
potionCost, _ := tuning.EffectiveNPCShopCosts()
if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance { 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.Gold -= healCost
h.HP = h.MaxHP h.HP = h.MaxHP
if al != nil { 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.Gold -= potionCost
h.Potions++ h.Potions++
if al != nil { 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 if npc.Type == "merchant" {
} share := cfg.MerchantTownAutoSellShare
hqs, err := s.questStore.ListHeroQuests(ctx, heroID) if share <= 0 || share > 1 {
if err != nil { share = tuning.DefaultValues().MerchantTownAutoSellShare
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 ok && al != nil { soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
qk := pick.QuestKey if soldItems > 0 && al != nil {
if qk == "" {
qk = fmt.Sprintf("quest.%d", pick.ID)
}
al(heroID, model.AdventureLogLine{ al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{ Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestAccepted, Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"questKey": qk}, Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
}, },
}) })
} }
default: return
// Other NPC types: treat as a social stop only. }
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. // 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) 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,19 +69,37 @@ type heroSummary struct {
UpdatedAt time.Time `json:"updatedAt"` 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). // adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct { type adminLiveMovementJSON struct {
Online bool `json:"online"` Online bool `json:"online"`
MoveState string `json:"moveState,omitempty"` MoveState string `json:"moveState,omitempty"`
RestUntil *time.Time `json:"restUntil,omitempty"` RestUntil *time.Time `json:"restUntil,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"` TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"` NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
CurrentTownID int64 `json:"currentTownId,omitempty"` CurrentTownID int64 `json:"currentTownId,omitempty"`
DestinationTownID int64 `json:"destinationTownId,omitempty"` DestinationTownID int64 `json:"destinationTownId,omitempty"`
WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"` WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
ExcursionPhase string `json:"excursionPhase,omitempty"` ExcursionKind string `json:"excursionKind,omitempty"`
ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"` ExcursionPhase string `json:"excursionPhase,omitempty"`
ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,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. // 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 s.WanderingMerchantDeadline = &t
} }
if hm.Excursion.Active() { if hm.Excursion.Active() {
s.ExcursionKind = string(hm.Excursion.Kind)
s.ExcursionPhase = string(hm.Excursion.Phase) s.ExcursionPhase = string(hm.Excursion.Phase)
if !hm.Excursion.WildUntil.IsZero() { if !hm.Excursion.WildUntil.IsZero() {
t := hm.Excursion.WildUntil t := hm.Excursion.WildUntil
@ -167,6 +186,39 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
s.ExcursionReturnUntil = &t 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 return s
} }
@ -2009,6 +2061,48 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) {
h.writeAdminHeroDetail(w, hero2) 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. // 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 // POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {

@ -884,8 +884,7 @@ func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
Open bool `json:"open"` Open bool `json:"open"`
AdvanceTownVisit bool `json:"advanceTownVisit"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) 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 return
} }
if h.engine != nil { if h.engine != nil {
if !req.Open && req.AdvanceTownVisit { h.engine.SetTownNPCUILock(hero.ID, req.Open)
h.engine.SkipTownNPCNarrationAfterDialog(hero.ID) if !req.Open {
h.engine.ClearMerchantStock(hero.ID) h.engine.ClearMerchantStock(hero.ID)
} else { h.engine.SkipTownNPCNarrationAfterDialog(hero.ID)
h.engine.SetTownNPCUILock(hero.ID, req.Open)
if !req.Open {
h.engine.ClearMerchantStock(hero.ID)
}
} }
} }
writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) 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. // ExcursionPhase tracks where the hero is within a mini-adventure session.
// The lifecycle is: Out → Wild → Return → (back to road, phase cleared). // 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 type ExcursionPhase string
const ( const (
@ -13,17 +14,29 @@ const (
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible) 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 type ExcursionKind string
const ( const (
ExcursionKindNone ExcursionKind = "" ExcursionKindNone ExcursionKind = ""
ExcursionKindRoadside ExcursionKind = "roadside" ExcursionKindRoadside ExcursionKind = "roadside"
ExcursionKindAdventure ExcursionKind = "adventure" ExcursionKindAdventure ExcursionKind = "adventure"
ExcursionKindTown ExcursionKind = "town"
) )
// ExcursionSession holds the live state of an active mini-adventure (off-road excursion). // TownTourPhase is the sub-state machine while ExcursionKind == town (StateInTown).
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued. 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 { type ExcursionSession struct {
Kind ExcursionKind Kind ExcursionKind
Phase ExcursionPhase Phase ExcursionPhase
@ -43,41 +56,79 @@ type ExcursionSession struct {
RoadFreezeFraction float64 RoadFreezeFraction float64
// Attractor-based movement (Kind != ""): hero walks in world space toward AttractorX/Y. // Attractor-based movement (Kind != ""): hero walks in world space toward AttractorX/Y.
StartX, StartY float64 StartX, StartY float64
AttractorX, AttractorY float64 AttractorX, AttractorY float64
AttractorSet bool AttractorSet bool
// Adventure-only: wall-time when wandering should end (then return to road). // Adventure-only: wall-time when wandering should end (then return to road).
AdventureEndsAt time.Time 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 WanderNextAt time.Time
// PendingReturnAfterCombat: adventure timer elapsed; wait for combat end then enter return phase. // PendingReturnAfterCombat: adventure timer elapsed; wait for combat end then enter return phase.
PendingReturnAfterCombat bool 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. // Active reports whether an excursion session is in progress.
func (s *ExcursionSession) Active() bool { func (s *ExcursionSession) Active() bool {
if s == nil {
return false
}
if s.Kind == ExcursionKindTown {
return s.TownTourPhase != ""
}
return s.Phase != ExcursionNone return s.Phase != ExcursionNone
} }
// ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the // 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. // heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure.
type ExcursionPersisted struct { type ExcursionPersisted struct {
Kind string `json:"kind,omitempty"` Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"` OutUntil *time.Time `json:"outUntil,omitempty"`
WildUntil *time.Time `json:"wildUntil,omitempty"` WildUntil *time.Time `json:"wildUntil,omitempty"`
ReturnUntil *time.Time `json:"returnUntil,omitempty"` ReturnUntil *time.Time `json:"returnUntil,omitempty"`
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"` DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"` RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"` RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
StartX float64 `json:"startX,omitempty"` StartX float64 `json:"startX,omitempty"`
StartY float64 `json:"startY,omitempty"` StartY float64 `json:"startY,omitempty"`
AttractorX float64 `json:"attractorX,omitempty"` AttractorX float64 `json:"attractorX,omitempty"`
AttractorY float64 `json:"attractorY,omitempty"` AttractorY float64 `json:"attractorY,omitempty"`
AttractorSet bool `json:"attractorSet,omitempty"` AttractorSet bool `json:"attractorSet,omitempty"`
AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"` AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"`
WanderNextAt *time.Time `json:"wanderNextAt,omitempty"` WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
PendingReturnAfterCombat bool `json:"pendingReturnAfterCombat,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"` RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise. // ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"` 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"` 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 holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"` TownPause *TownPausePersisted `json:"-"`

@ -174,6 +174,25 @@ type TownNPCVisitPayload struct {
WorldY float64 `json:"worldY"` 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. // AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log.
type AdventureLogLinePayload = AdventureLogLine 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-adventure", adminH.StopHeroExcursion)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest) r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest)
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown) 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.Post("/heroes/{heroId}/trigger-random-encounter", adminH.TriggerRandomEncounter)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear) r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear) 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. // (same duration/regen as towns without NPCs). Otherwise only a short TownNPCPauseMs wait.
TownAfterNPCRestChance float64 `json:"townAfterNpcRestChance"` 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"` WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"`
MerchantCostBase int64 `json:"merchantCostBase"` MerchantCostBase int64 `json:"merchantCostBase"`
MerchantCostPerLevel int64 `json:"merchantCostPerLevel"` MerchantCostPerLevel int64 `json:"merchantCostPerLevel"`
@ -282,6 +293,15 @@ func DefaultValues() Values {
TownNPCWalkSpeed: 3.0, TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65, TownNPCStandoffWorld: 0.65,
TownAfterNPCRestChance: 0.78, 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, WanderingMerchantPromptTimeoutMs: 15_000,
MerchantCostBase: 20, MerchantCostBase: 20,
MerchantCostPerLevel: 5, MerchantCostPerLevel: 5,

@ -44,7 +44,7 @@ UPDATE public.runtime_config
SET SET
payload = payload || jsonb_build_object( payload = payload || jsonb_build_object(
'enemyEncounterStatMultiplier', 1.2, 'enemyEncounterStatMultiplier', 1.2,
'enemyStatMultiplierVsUnequippedHero', 0.75 'enemyStatMultiplierVsUnequippedHero', 0.85
), ),
updated_at = now() updated_at = now()
WHERE id = true; WHERE id = true;

@ -124,6 +124,30 @@ Server always sends `WSMessage`. Client always sends `WSMessage`. The `readPump`
{"type":"activate_buff","payload":{"buffType":"rage"}} {"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 ### Step-by-Step Changes
#### 1. Fix WS protocol — `internal/handler/ws.go` #### 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. This spec is the contract between the backend and frontend agents.
Each phase is independently deployable. Phases must ship in order. 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 ## 1. WebSocket Message Protocol

@ -10,6 +10,9 @@ import {
sendRevive, sendRevive,
sendNPCAlmsAccept, sendNPCAlmsAccept,
sendNPCAlmsDecline, sendNPCAlmsDecline,
sendTownTourNPCDialogClosed,
sendTownTourNPCInteractionOpened,
sendTownTourNPCInteractionClosed,
buildLootFromCombatEnd, buildLootFromCombatEnd,
buildMerchantLootDrop, buildMerchantLootDrop,
} from './game/ws-handler'; } from './game/ws-handler';
@ -32,7 +35,16 @@ import {
offlineReportHasActivity, offlineReportHasActivity,
} from './network/api'; } from './network/api';
import type { HeroResponse, Achievement, ChangelogPayload } 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 type { OfflineReport as OfflineReportData } from './network/api';
import { import {
BUFF_COOLDOWN_MS, BUFF_COOLDOWN_MS,
@ -223,6 +235,22 @@ function mapEquipment(
return out; 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 */ /** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */
function townToTownData( function townToTownData(
town: Town, town: Town,
@ -268,9 +296,14 @@ function heroResponseToState(res: HeroResponse): HeroState {
restKind: res.restKind, restKind: res.restKind,
excursionPhase: res.excursionPhase, excursionPhase: res.excursionPhase,
excursionKind: excursionKind:
res.excursionKind === 'roadside' || res.excursionKind === 'adventure' res.excursionKind === 'roadside' ||
? res.excursionKind res.excursionKind === 'adventure' ||
res.excursionKind === 'town'
? (res.excursionKind as HeroState['excursionKind'])
: undefined, : undefined,
townTourPhase: res.townTourPhase,
townTourNpcId: res.townTourNpcId,
townTourExitPending: res.townTourExitPending,
attackSpeed: res.attackSpeed ?? res.speed, attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack, damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense, defense: res.defensePower ?? res.defense,
@ -379,11 +412,26 @@ export function App() {
const [heroSheetOpen, setHeroSheetOpen] = useState(false); const [heroSheetOpen, setHeroSheetOpen] = useState(false);
const [heroSheetInitialTab, setHeroSheetInitialTab] = useState<HeroSheetTab>('stats'); 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 [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | 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 [townTourLastPayload, setTownTourLastPayload] = useState<TownTourPhasePayload | null>(null);
const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState<NPCData | 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 // Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null); const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
@ -894,38 +942,39 @@ export function App() {
setToast({ message: t(bundle.entering, { townName: townDisp }), color: '#daa520' }); setToast({ message: t(bundle.entering, { townName: townDisp }), color: '#daa520' });
appendLogClientMessage(formatClientLogLine(bundle, 'enteredTown', { town: townDisp })); appendLogClientMessage(formatClientLogLine(bundle, 'enteredTown', { town: townDisp }));
setNearestNPC(null); setNearestNPC(null);
setNpcVisitAwaitingProximity(null);
setSelectedNPC(null); setSelectedNPC(null);
setNpcInteractionDismissed(null); setNpcInteractionDismissed(null);
setTownTourLastPayload(null);
}, },
onAdventureLogLine: (p) => { onAdventureLogLine: (p) => {
appendLogPayload(p); appendLogPayload(p);
}, },
onTownNPCVisit: (p) => { onTownNPCVisit: () => {
const loc = i18nForLogRef.current.locale; // Town NPC UI is driven by `town_tour_phase` (engine still gets lines via ws-handler).
setNearestNPC(null); },
setNpcInteractionDismissed(null);
const displayName = p.nameKey ? npcLabel(loc, p.nameKey, p.name) : p.name; onTownTourPhase: (p) => {
const tw = townsRef.current.find((t) => t.id === p.townId); setTownTourLastPayload(p);
setNpcVisitAwaitingProximity({ const ph = p.phase;
id: p.npcId, if (ph === 'npc_welcome' || ph === 'npc_service') {
name: displayName, setNpcInteractionDismissed(null);
nameKey: p.nameKey, }
type: p.type as NPCData['type'], if (ph === 'wander' || ph === 'rest' || ph === 'npc_approach') {
worldX: p.worldX ?? 0, setSelectedNPC(null);
worldY: p.worldY ?? 0, }
townId: p.townId, },
townLevelMin: tw?.levelMin ?? 1,
townLevelMax: tw?.levelMax ?? 1, onTownTourServiceEnd: () => {
}); setTownTourLastPayload(null);
setSelectedNPC(null);
}, },
onTownExit: () => { onTownExit: () => {
setCurrentTown(null); setCurrentTown(null);
setNearestNPC(null); setNearestNPC(null);
setNpcVisitAwaitingProximity(null); setTownTourLastPayload(null);
}, },
onNPCEncounter: (p) => { 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 // Restore per-hero buff button cooldowns
useEffect(() => { useEffect(() => {
const id = gameState.hero?.id; const id = gameState.hero?.id;
@ -1353,11 +1365,16 @@ export function App() {
setNpcInteractionDismissed(npc.id); setNpcInteractionDismissed(npc.id);
}, []); }, []);
const handleNPCInteractionDismiss = useCallback(() => { const handleTownTourInteractionDismiss = useCallback(() => {
if (nearestNPC) { const w = wsRef.current;
setNpcInteractionDismissed(nearestNPC.id); 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) ---- // ---- Wandering NPC Encounter Handlers (via WS) ----
@ -1379,13 +1396,33 @@ export function App() {
appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'declinedWanderingMerchant')); appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'declinedWanderingMerchant'));
}, [appendLogClientMessage]); }, [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 = const showNPCInteraction =
nearestNPC !== null && interactionNpc != null &&
npcInteractionDismissed !== nearestNPC.id && npcInteractionDismissed !== interactionNpc.id &&
(gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) && (gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) &&
!selectedNPC; !selectedNPC;
const dialogNpc = selectedNPC;
const handleNPCInteractionDismiss = useCallback(() => {
if (interactionNpc) setNpcInteractionDismissed(interactionNpc.id);
}, [interactionNpc]);
const completedQuestCount = useMemo( const completedQuestCount = useMemo(
() => () =>
heroQuests.filter((q) => q.status === 'completed').length, heroQuests.filter((q) => q.status === 'completed').length,
@ -1497,20 +1534,21 @@ export function App() {
</div> </div>
)} )}
{/* NPC Proximity Interaction */} {/* Town tour service chip or legacy proximity NPC */}
{showNPCInteraction && nearestNPC && ( {showNPCInteraction && interactionNpc && (
<NPCInteraction <NPCInteraction
npc={nearestNPC} npc={interactionNpc}
onViewQuests={handleNPCViewQuests} onViewQuests={handleNPCViewQuests}
onOpenServiceDialog={handleNPCViewQuests} onOpenServiceDialog={handleNPCViewQuests}
onDismiss={handleNPCInteractionDismiss} onDismiss={handleNPCInteractionDismiss}
onDismissTownTour={townTourInteractionNPC ? handleTownTourInteractionDismiss : undefined}
/> />
)} )}
{/* NPC Dialog */} {/* NPC Dialog: opened from interaction / sheet only (not auto on town tour approach). */}
{selectedNPC && ( {dialogNpc && (
<NPCDialog <NPCDialog
npc={selectedNPC} npc={dialogNpc}
heroQuests={heroQuests} heroQuests={heroQuests}
heroGold={gameState.hero?.gold ?? 0} heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost} potionCost={npcShopCosts.potionCost}
@ -1521,6 +1559,7 @@ export function App() {
onHeroUpdated={handleNPCHeroUpdated} onHeroUpdated={handleNPCHeroUpdated}
onToast={(message, color) => setToast({ message, color })} onToast={(message, color) => setToast({ message, color })}
questClaimDisabled={questClaimDisabled} questClaimDisabled={questClaimDisabled}
townTourWs={heroOnTownTour && selectedNPC ? townTourDialogWs : undefined}
/> />
)} )}

@ -138,8 +138,12 @@ export interface HeroState {
restKind?: string; restKind?: string;
/** Mini-adventure leg: "out" | "wild" | "return" when excursion active */ /** Mini-adventure leg: "out" | "wild" | "return" when excursion active */
excursionPhase?: string; excursionPhase?: string;
/** Attractor excursion mode from server: roadside rest vs walking adventure */ /** Attractor excursion mode from server: roadside rest vs walking adventure vs in-town tour */
excursionKind?: 'roadside' | 'adventure'; excursionKind?: 'roadside' | 'adventure' | 'town';
/** Sub-phase during `excursionKind: town` (server-driven NPC UI). */
townTourPhase?: string;
townTourNpcId?: number;
townTourExitPending?: boolean;
attackSpeed: number; attackSpeed: number;
damage: number; damage: number;
defense: number; defense: number;
@ -461,6 +465,8 @@ export type ServerMessageType =
| 'town_enter' | 'town_enter'
| 'town_exit' | 'town_exit'
| 'town_npc_visit' | 'town_npc_visit'
| 'town_tour_phase'
| 'town_tour_service_end'
| 'npc_encounter' | 'npc_encounter'
| 'npc_encounter_end' | 'npc_encounter_end'
| 'level_up' | 'level_up'
@ -604,6 +610,24 @@ export interface TownNPCVisitPayload {
worldY: number; 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). */ /** Server-persisted adventure log line (legacy uses message; new rows use event). */
export interface AdventureLogLinePayload { export interface AdventureLogLinePayload {
message?: string; message?: string;

@ -13,6 +13,8 @@ import type {
BuffAppliedPayload, BuffAppliedPayload,
TownEnterPayload, TownEnterPayload,
TownNPCVisitPayload, TownNPCVisitPayload,
TownTourPhasePayload,
TownTourServiceEndPayload,
AdventureLogLinePayload, AdventureLogLinePayload,
NPCEncounterPayload, NPCEncounterPayload,
NPCEncounterEndPayload, NPCEncounterEndPayload,
@ -43,6 +45,8 @@ export interface WSHandlerCallbacks {
onBuffApplied?: (payload: BuffAppliedPayload) => void; onBuffApplied?: (payload: BuffAppliedPayload) => void;
onTownEnter?: (payload: TownEnterPayload) => void; onTownEnter?: (payload: TownEnterPayload) => void;
onTownNPCVisit?: (payload: TownNPCVisitPayload) => void; onTownNPCVisit?: (payload: TownNPCVisitPayload) => void;
onTownTourPhase?: (payload: TownTourPhasePayload) => void;
onTownTourServiceEnd?: (payload: TownTourServiceEndPayload) => void;
onAdventureLogLine?: (payload: AdventureLogLinePayload) => void; onAdventureLogLine?: (payload: AdventureLogLinePayload) => void;
onTownExit?: () => void; onTownExit?: () => void;
onNPCEncounter?: (payload: NPCEncounterPayload) => void; onNPCEncounter?: (payload: NPCEncounterPayload) => void;
@ -200,6 +204,16 @@ export function wireWSHandler(
callbacks.onTownNPCVisit?.(p); 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) => { ws.on('adventure_log_line', (msg: ServerMessage) => {
const p = msg.payload as AdventureLogLinePayload; const p = msg.payload as AdventureLogLinePayload;
callbacks.onAdventureLogLine?.(p); callbacks.onAdventureLogLine?.(p);
@ -294,6 +308,18 @@ export function sendNPCAlmsDecline(ws: GameWebSocket): void {
ws.send('npc_alms_decline', {}); 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. * Build a LootDrop from combat_end payload for the loot popup UI.
*/ */

@ -110,6 +110,9 @@ export interface HeroResponse {
restKind?: string; restKind?: string;
excursionPhase?: string; excursionPhase?: string;
excursionKind?: string; excursionKind?: string;
townTourPhase?: string;
townTourNpcId?: number;
townTourExitPending?: boolean;
/** Removed from server; gear.main_hand / legacy weapon only */ /** Removed from server; gear.main_hand / legacy weapon only */
weaponId?: number; weaponId?: number;
armorId?: number; armorId?: number;
@ -684,18 +687,10 @@ export interface MerchantStockResponse {
items: MerchantGearOfferItem[]; items: MerchantGearOfferItem[];
} }
/** Freeze town NPC visit timers while quest/shop UI is open (POST /hero/npc-dialog-pause). */ /** Freeze town tour welcome/service timers while NPCDialog is open (POST /hero/npc-dialog-pause). */
export async function setNPCDialogPause( export async function setNPCDialogPause(open: boolean, telegramId?: number): Promise<void> {
open: boolean,
telegramId?: number,
opts?: { advanceTownVisit?: boolean },
): Promise<void> {
const query = telegramId != null ? `?telegramId=${telegramId}` : ''; const query = telegramId != null ? `?telegramId=${telegramId}` : '';
const body: { open: boolean; advanceTownVisit?: boolean } = { open }; await apiPost<{ ok: boolean }>(`/hero/npc-dialog-pause${query}`, { open });
if (!open && opts?.advanceTownVisit) {
body.advanceTownVisit = true;
}
await apiPost<{ ok: boolean }>(`/hero/npc-dialog-pause${query}`, body);
} }
/** Roll merchant stock for this town tier (POST /hero/npc-merchant-stock). */ /** Roll merchant stock for this town tier (POST /hero/npc-merchant-stock). */

@ -34,6 +34,14 @@ interface NPCDialogProps {
onToast: (message: string, color: string) => void; onToast: (message: string, color: string) => void;
/** Block quest reward claim while hero is dead. */ /** Block quest reward claim while hero is dead. */
questClaimDisabled?: boolean; 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 ---- // ---- Styles ----
@ -290,6 +298,7 @@ export function NPCDialog({
onHeroUpdated, onHeroUpdated,
onToast, onToast,
questClaimDisabled = false, questClaimDisabled = false,
townTourWs,
}: NPCDialogProps) { }: NPCDialogProps) {
const tr = useT(); const tr = useT();
const { locale } = useLocale(); const { locale } = useLocale();
@ -302,19 +311,21 @@ export function NPCDialog({
const telegramId = getTelegramUserId() ?? 1; 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setNPCDialogPause(true, telegramId).catch((err) => { setNPCDialogPause(true, telegramId).catch((err) => {
if (!cancelled) console.warn('[NPCDialog] npc-dialog-pause (open) failed:', err); if (!cancelled) console.warn('[NPCDialog] npc-dialog-pause (open) failed:', err);
}); });
townTourWs?.onInteractionOpened();
return () => { return () => {
cancelled = true; cancelled = true;
setNPCDialogPause(false, telegramId, { advanceTownVisit: true }).catch((err) => { setNPCDialogPause(false, telegramId).catch((err) => {
console.warn('[NPCDialog] npc-dialog-pause (close) failed:', 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). // Merchant: roll stock when the shop opens (server-tier gear for this town).
useEffect(() => { useEffect(() => {

@ -10,6 +10,8 @@ interface NPCInteractionProps {
/** Open shop (merchant) or services (healer) dialog. */ /** Open shop (merchant) or services (healer) dialog. */
onOpenServiceDialog: (npc: NPCData) => void; onOpenServiceDialog: (npc: NPCData) => void;
onDismiss: () => void; onDismiss: () => void;
/** When set, called before onDismiss for town tour (server interaction_closed). */
onDismissTownTour?: () => void;
} }
// ---- Styles ---- // ---- Styles ----
@ -102,6 +104,7 @@ export function NPCInteraction({
onViewQuests, onViewQuests,
onOpenServiceDialog, onOpenServiceDialog,
onDismiss, onDismiss,
onDismissTownTour,
}: NPCInteractionProps) { }: NPCInteractionProps) {
const tr = useT(); const tr = useT();
const info = npcColor(npc.type, tr); const info = npcColor(npc.type, tr);
@ -157,7 +160,10 @@ export function NPCInteraction({
<div style={npcTypeStyle}>{info.text}</div> <div style={npcTypeStyle}>{info.text}</div>
</div> </div>
<button <button
onClick={onDismiss} onClick={() => {
onDismissTownTour?.();
onDismiss();
}}
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',

Loading…
Cancel
Save