Compare commits

..

5 Commits

@ -31,6 +31,9 @@ All messages (both directions) use `{"type": string, "payload": object}`. Text `
| `level_up` | `newLevel`, all stat fields |
| `buff_applied` | `buffType`, `magnitude`, `durationMs`, `expiresAt` |
| `debuff_applied` | `debuffType`, `magnitude`, `durationMs`, `expiresAt` |
| `town_tour_phase` | `phase`, `townId`, optional NPC fields, `exitPending` |
| `town_tour_service_end` | `reason` (e.g. timeout) |
| `town_npc_visit` | NPC stand snapshot (log/engine; UI follows `town_tour_phase`) |
### Client → Server Commands
@ -39,6 +42,9 @@ All messages (both directions) use `{"type": string, "payload": object}`. Text `
| `request_encounter` | `{}` |
| `request_revive` | `{}` |
| `activate_buff` | `{"buffType": "rage"}` |
| `town_tour_npc_interaction_opened` | `{}` |
| `town_tour_npc_dialog_closed` | `{}` |
| `town_tour_npc_interaction_closed` | `{}` |
## Critical Gaps (P0 — must fix)

@ -197,6 +197,9 @@
gearFilterSubtype: "",
grantGearSearchQuery: "",
heroGrantGearCandidates: [],
/** NPC list for «Подойти к NPC» (town tour admin); from GET quests/towns/{id}/npcs */
townTourApproachNpcs: [],
townTourApproachNpcTownId: null,
heroGrantFilterSlot: "",
heroGrantFilterRarity: "",
heroGrantFilterSubtype: "",
@ -1139,13 +1142,35 @@
if (h.currentTownId != null) rows.push(`<div class="kv"><kbd>currentTownId</kbd><div>${e(h.currentTownId)}</div></div>`);
if (h.destinationTownId != null) rows.push(`<div class="kv"><kbd>destinationTownId</kbd><div>${e(h.destinationTownId)}</div></div>`);
if (h.restKind) rows.push(`<div class="kv"><kbd>restKind</kbd><div>${e(h.restKind)}</div></div>`);
if (h.excursionKind) {
rows.push(`<div class="kv"><kbd>excursionKind (hero)</kbd><div>${e(h.excursionKind)}</div></div>`);
}
if (live && live.online) {
if (live.excursionKind) rows.push(`<div class="kv"><kbd>excursionKind (live)</kbd><div>${e(live.excursionKind)}</div></div>`);
if (live.excursionPhase) rows.push(`<div class="kv"><kbd>excursionPhase</kbd><div>${e(live.excursionPhase)}</div></div>`);
if (live.restUntil) rows.push(`<div class="kv"><kbd>отдых / restUntil</kbd><div>${statusCountdownLine(live.restUntil)}</div></div>`);
if (live.townLeaveAt) rows.push(`<div class="kv"><kbd>в городе до выхода</kbd><div>${statusCountdownLine(live.townLeaveAt)}</div></div>`);
if (live.nextTownNPCRollAt) rows.push(`<div class="kv"><kbd>след. событие NPC в городе</kbd><div>${statusCountdownLine(live.nextTownNPCRollAt)}</div></div>`);
if (live.wanderingMerchantDeadline) {
rows.push(`<div class="kv"><kbd>окно бродячего торговца</kbd><div>${statusCountdownLine(live.wanderingMerchantDeadline)}</div></div>`);
}
const tt = live.townTour;
if (tt && typeof tt === "object") {
rows.push(`<div style="grid-column:1/-1;margin-top:8px;padding-top:8px;border-top:1px solid #2a3551;font-weight:600;color:#cfe3ff">Экскурсия по городу (town tour)</div>`);
if (tt.phase) rows.push(`<div class="kv"><kbd>phase</kbd><div>${e(tt.phase)}</div></div>`);
if (tt.npcId) rows.push(`<div class="kv"><kbd>npcId</kbd><div>${e(tt.npcId)}</div></div>`);
if (tt.townTourEndsAt) rows.push(`<div class="kv"><kbd>конец пребывания в городе</kbd><div>${statusCountdownLine(tt.townTourEndsAt)}</div></div>`);
if (tt.wanderNextAt) rows.push(`<div class="kv"><kbd>след. смена аттрактора</kbd><div>${statusCountdownLine(tt.wanderNextAt)}</div></div>`);
if (tt.townWelcomeUntil) rows.push(`<div class="kv"><kbd>welcome до</kbd><div>${statusCountdownLine(tt.townWelcomeUntil)}</div></div>`);
if (tt.townServiceUntil) rows.push(`<div class="kv"><kbd>service до</kbd><div>${statusCountdownLine(tt.townServiceUntil)}</div></div>`);
if (tt.townRestUntil) rows.push(`<div class="kv"><kbd>отдых в туре</kbd><div>${statusCountdownLine(tt.townRestUntil)}</div></div>`);
if (tt.townExitPending) rows.push(`<div class="kv"><kbd>выход из города</kbd><div>ожидает безопасной фазы</div></div>`);
if (tt.townTourDialogOpen) rows.push(`<div class="kv"><kbd>UI</kbd><div>NPCDialog открыт (таймеры сдвигаются)</div></div>`);
if (tt.townTourInteractionOpen) rows.push(`<div class="kv"><kbd>UI</kbd><div>interaction открыт</div></div>`);
if (tt.townTourStandX != null && tt.townTourStandY != null) {
rows.push(`<div class="kv"><kbd>stand</kbd><div>${e(tt.townTourStandX)}, ${e(tt.townTourStandY)}</div></div>`);
}
}
}
if (tp) {
if (tp.restUntil) rows.push(`<div class="kv"><kbd>отдых (из БД)</kbd><div>${e(tp.restKind || "")}: ${statusCountdownLine(tp.restUntil)}</div></div>`);
@ -1202,8 +1227,47 @@
state.selectedHeroId = heroId;
const [hero, gear, quests] = await Promise.all([api(`heroes/${heroId}`), api(`heroes/${heroId}/gear`), api(`heroes/${heroId}/quests`)]);
state.selectedHero = hero; state.gear = gear; state.quests = quests;
const newTid = hero.currentTownId;
if (Number(state.townTourApproachNpcTownId) !== Number(newTid)) {
state.townTourApproachNpcs = [];
state.townTourApproachNpcTownId = null;
}
render();
}
async function loadTownTourApproachNpcs() {
const h = state.selectedHero;
if (!h || !state.selectedHeroId) {
setMessage("Сначала выберите героя");
return;
}
const townId = h.currentTownId;
if (!townId) {
setMessage("У героя нет currentTownId (не в городе?)");
return;
}
const data = await api(`quests/towns/${townId}/npcs`);
state.townTourApproachNpcs = data.npcs || [];
state.townTourApproachNpcTownId = townId;
setMessage(`NPC города #${townId}: ${state.townTourApproachNpcs.length}`);
render();
}
async function townTourApproachSelectedNpc() {
if (!state.selectedHeroId) return;
const sel = document.getElementById("town-tour-approach-npc-select");
const raw = sel && sel.value ? String(sel.value).trim() : "";
const npcId = raw ? Number(raw) : 0;
if (!npcId) {
setMessage("Выберите NPC в списке");
return;
}
await api(`heroes/${state.selectedHeroId}/town-tour-approach-npc`, {
method: "POST",
body: JSON.stringify({ npcId }),
});
await loadHero(state.selectedHeroId);
startHeroMovementPoll(60);
setMessage(`Подход к NPC #${npcId} (town tour)`);
}
async function heroAction(action, body = {}, pollMovement = false) {
if (!state.selectedHeroId) return;
await api(`heroes/${state.selectedHeroId}/${action}`, { method: "POST", body: JSON.stringify(body) });
@ -2031,6 +2095,7 @@
let heroExtra = "";
let teleportOpts = `<option value="">— town —</option>`;
let townTourApproachPanel = "";
if (state.selectedHeroId) {
const rowsDbH = state.contentGearRows || [];
const rowsCatH = state.gearCatalog || [];
@ -2049,6 +2114,22 @@
const hgSubOpts = `<option value="">All subtypes</option>` + hgSubs.map(s => `<option value="${e(s)}" ${state.heroGrantFilterSubtype === s ? "selected" : ""}>${e(s)}</option>`).join("");
const hgRarOpts = `<option value="">All rarities</option>` + hgRars.map(s => `<option value="${e(s)}" ${state.heroGrantFilterRarity === s ? "selected" : ""}>${e(s)}</option>`).join("");
teleportOpts = `<option value="">— town —</option>` + (state.teleportTowns || []).map(t => `<option value="${t.id}">${e(t.name)} (#${t.id})</option>`).join("");
const liveMov = h.adminLiveMovement;
const showTownTourAdmin = h.excursionKind === "town" && liveMov && liveMov.online;
const npcListApproach = state.townTourApproachNpcs || [];
const npcOptsApproach = npcListApproach.map(n =>
`<option value="${e(String(n.id))}">#${e(n.id)} ${e(n.name || "")} (${e(n.type || "")})</option>`
).join("");
townTourApproachPanel = showTownTourAdmin ? `
<div style="margin-top:12px;padding-top:12px;border-top:1px solid #2a3551">
<h4 style="margin:0 0 8px;font-size:14px;color:#cfe3ff">Экскурсия по городу</h4>
<p class="muted" style="margin:0 0 8px;font-size:12px">Только для онлайн-героя с <kbd>excursionKind=town</kbd>. Текущий город: <kbd>${e(h.currentTownId)}</kbd>. Детали — блок «Путь, город, отдых».</p>
<button type="button" class="btn" onclick="withAction(loadTownTourApproachNpcs)">Загрузить NPC текущего города</button>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<select id="town-tour-approach-npc-select" style="min-width:200px;max-width:340px"><option value="">— NPC —</option>${npcOptsApproach}</select>
<button type="button" class="btn" onclick="withAction(townTourApproachSelectedNpc)">Подойти к NPC</button>
</div>
</div>` : "";
const equipped = state.gear?.equipped || {};
const inventory = state.gear?.inventory || [];
const slotRows = Object.keys(equipped).sort().map(slot => `
@ -2214,6 +2295,7 @@
<div><label class="muted">&nbsp;</label><br /><button type="button" class="btn" onclick="withAction(teleportHeroToTown)">Teleport</button></div>
</div>
<p class="muted" style="margin-top:6px;margin-bottom:0">Города из графа (<kbd>GET /admin/towns</kbd>). Герой жив и не в бою.</p>
${townTourApproachPanel}
</aside>
</div>
<div style="margin-top:14px;padding-top:12px;border-top:1px solid #2a3551">

@ -3,10 +3,12 @@ module github.com/denisovdennis/autohero
go 1.23
require (
github.com/TwiN/go-away v1.6.12
github.com/go-chi/chi/v5 v5.1.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.1
github.com/redis/go-redis/v9 v9.7.0
golang.org/x/text v0.18.0
)
require (
@ -17,5 +19,4 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/text v0.18.0 // indirect
)

@ -1,3 +1,5 @@
github.com/TwiN/go-away v1.6.12 h1:80AjDyeTjfQaSFYbALzRcDKMAmxKW0a5PoxwXKZlW2A=
github.com/TwiN/go-away v1.6.12/go.mod h1:MpvIC9Li3minq+CGgbgUDvQ9tDaeW35k5IXZrF9MVas=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=

@ -1,5 +1,28 @@
{
"releases": [
{
"version": "0.2.0-dev",
"title": "AutoHero — 0.2.0",
"items": [
"WIPE!",
"Fixed buffs being applied while dead",
"Fixed weapon and armour drops",
"Reduced wandering merchant encounter chance",
"Fixed dead screen",
"Fully reworked combat flow and monster balance",
"New monsters added",
"XP and gold rewards rebalanced",
"New offline digest",
"Updated offline mode",
"Fixed adventure movement and roadside behaviour",
"New quests added",
"Monster and hero gear rebalance",
"You can now buy equipment in the merchant's shop in town",
"Buff adjustments",
"Town visits reworked",
"Something else"
]
},
{
"version": "0.1.1-dev",
"title": "AutoHero — 0.1.1",

@ -400,6 +400,12 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) {
e.handleNPCAlmsAccept(msg)
case "npc_alms_decline":
e.handleNPCAlmsDecline(msg)
case "town_tour_npc_dialog_closed":
e.handleTownTourNPCDialogClosed(msg)
case "town_tour_npc_interaction_opened":
e.handleTownTourNPCInteractionOpened(msg)
case "town_tour_npc_interaction_closed":
e.handleTownTourNPCInteractionClosed(msg)
default:
// Commands like accept_quest, claim_quest, npc_interact etc.
// are handled by their respective REST handlers for now.
@ -855,6 +861,26 @@ func (e *Engine) ApplyAdminForceLeaveTown(heroID int64) (*model.Hero, bool) {
return h, true
}
// ApplyAdminTownTourApproachNPC forces npc_approach toward a specific NPC during ExcursionKindTown (hero must be online).
func (e *Engine) ApplyAdminTownTourApproachNPC(heroID, npcID int64) (*model.Hero, error) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[heroID]
if !ok || e.roadGraph == nil {
return nil, fmt.Errorf("hero not online or graph missing")
}
now := time.Now()
if err := hm.AdminTownTourApproachNPC(e.roadGraph, npcID, now); err != nil {
return nil, err
}
hm.SyncToHero()
h := hm.Hero
if e.sender != nil {
NotifyTownTourClients(e.sender, heroID, hm, e.roadGraph, now)
}
return h, nil
}
// ApplyAdminStartRest puts an online hero into town-style rest at the current location.
func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
@ -1987,7 +2013,7 @@ func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
}
}
// SetTownNPCUILock freezes town NPC visit narration while the client shows shop or quest UI.
// SetTownNPCUILock freezes town tour welcome/service timers while the client shows NPCDialog.
func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
if e == nil {
return
@ -1998,11 +2024,14 @@ func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
if hm == nil {
return
}
if hm.Excursion.Kind == model.ExcursionKindTown {
hm.townTourSetDialogOpen(locked)
return
}
hm.TownNPCUILock = locked
}
// SkipTownNPCNarrationAfterDialog ends the current town NPC visit narration immediately when
// the client closes shop / healer / quest UI (next tick proceeds to the next NPC or plaza).
// SkipTownNPCNarrationAfterDialog applies town tour dialog-closed semantics (legacy name for REST).
func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
if e == nil {
return
@ -2010,10 +2039,49 @@ func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
e.mu.Lock()
defer e.mu.Unlock()
hm := e.movements[heroID]
if hm == nil {
if hm == nil || e.roadGraph == nil {
return
}
hm.skipTownNPCNarrationForDialogClose(time.Now())
hm.townTourNPCDialogClosed(time.Now(), e.roadGraph)
}
func (e *Engine) handleTownTourNPCDialogClosed(msg IncomingMessage) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[msg.HeroID]
if !ok || e.roadGraph == nil {
return
}
hm.townTourNPCDialogClosed(time.Now(), e.roadGraph)
if e.sender != nil && hm.Hero != nil {
e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
}
}
func (e *Engine) handleTownTourNPCInteractionOpened(msg IncomingMessage) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[msg.HeroID]
if !ok || e.roadGraph == nil {
return
}
hm.townTourNPCInteractionOpened(time.Now(), e.roadGraph)
if e.sender != nil && hm.Hero != nil {
e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
}
}
func (e *Engine) handleTownTourNPCInteractionClosed(msg IncomingMessage) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[msg.HeroID]
if !ok || e.roadGraph == nil {
return
}
hm.townTourNPCInteractionClosed(time.Now(), e.roadGraph)
if e.sender != nil && hm.Hero != nil {
e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
}
}
// SetMerchantStock replaces ephemeral merchant offers for a hero (copies items, ids cleared).

@ -120,6 +120,9 @@ type HeroMovement struct {
// lastTownPausePersistSignature tracks the last persisted excursion/rest snapshot so we can
// persist only on meaningful changes (start/end/phase change).
lastTownPausePersistSignature townPausePersistSignature
// sentTownTourWireSig avoids spamming town_tour_phase when nothing changed.
sentTownTourWireSig string
}
// townPausePersistSignature captures the excursion/rest fields that should trigger persistence.
@ -539,6 +542,14 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
hm.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil)
hm.TownLeaveAt = shift(hm.TownLeaveAt)
if hm.Excursion.Kind == model.ExcursionKindTown {
ex := &hm.Excursion
ex.TownTourEndsAt = shift(ex.TownTourEndsAt)
ex.WanderNextAt = shift(ex.WanderNextAt)
ex.TownWelcomeUntil = shift(ex.TownWelcomeUntil)
ex.TownServiceUntil = shift(ex.TownServiceUntil)
ex.TownRestUntil = shift(ex.TownRestUntil)
}
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil)
@ -744,6 +755,9 @@ func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
if !hm.Excursion.Active() {
return false
}
if hm.Excursion.Kind == model.ExcursionKindTown {
return false
}
if hm.State == model.StateFighting {
return false
}
@ -870,7 +884,7 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
}
func (hm *HeroMovement) excursionUsesAttractors() bool {
return hm != nil && hm.Excursion.Active() && hm.Excursion.Kind != model.ExcursionKindNone
return hm != nil && hm.Excursion.Active() && hm.Excursion.Kind != model.ExcursionKindNone && hm.Excursion.Kind != model.ExcursionKindTown
}
func excursionArrivalEpsilon() float64 {
@ -1123,28 +1137,20 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (mons
return false, model.Enemy{}, true
}
// EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there
// EnterTown transitions the hero into the destination town: town tour excursion (StateInTown) when there
// are NPCs, otherwise a short resting state (StateResting).
func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
destID := hm.DestinationTownID
hm.CurrentTownID = destID
hm.DestinationTownID = 0
hm.Road = nil
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
clearLegacyTownNPCState(hm)
hm.TownRestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.sentTownTourWireSig = ""
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.clearNPCWalk()
hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false
ids := graph.TownNPCIDs(destID)
if len(ids) == 0 {
@ -1155,31 +1161,20 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
return
}
q := make([]int64, len(ids))
copy(q, ids)
rand.Shuffle(len(q), func(i, j int) { q[i], q[j] = q[j], q[i] })
hm.TownNPCQueue = q
hm.State = model.StateInTown
hm.Hero.State = model.StateInTown
hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay())
beginTownTourExcursion(hm, now, graph)
}
// LeaveTown transitions the hero from town to walking, picking a new destination.
func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
clearLegacyTownNPCState(hm)
hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.clearNPCWalk()
hm.sentTownTourWireSig = ""
hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false
hm.State = model.StateWalking
@ -1329,9 +1324,19 @@ func (hm *HeroMovement) SyncToHero() {
}
hm.Hero.ExcursionPhase = model.ExcursionNone
hm.Hero.ExcursionKind = model.ExcursionKindNone
hm.Hero.TownTourPhase = ""
hm.Hero.TownTourNpcID = 0
hm.Hero.TownTourExitPending = false
if hm.Excursion.Active() {
hm.Hero.ExcursionPhase = hm.Excursion.Phase
hm.Hero.ExcursionKind = hm.Excursion.Kind
if hm.Excursion.Kind == model.ExcursionKindTown {
hm.Hero.ExcursionPhase = model.ExcursionWild
hm.Hero.TownTourPhase = hm.Excursion.TownTourPhase
hm.Hero.TownTourNpcID = hm.Excursion.TownTourNpcID
hm.Hero.TownTourExitPending = hm.Excursion.TownExitPending
} else {
hm.Hero.ExcursionPhase = hm.Excursion.Phase
}
}
hm.Hero.TownPause = hm.townPauseBlob()
}
@ -1516,6 +1521,31 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
t := s.WanderNextAt
ep.WanderNextAt = &t
}
if s.Kind == model.ExcursionKindTown {
ep.TownTourPhase = s.TownTourPhase
ep.TownTourNpcID = s.TownTourNpcID
ep.TownTourStandX = s.TownTourStandX
ep.TownTourStandY = s.TownTourStandY
ep.TownExitPending = s.TownExitPending
ep.TownTourDialogOpen = s.TownTourDialogOpen
ep.TownTourInteractionOpen = s.TownTourInteractionOpen
if !s.TownTourEndsAt.IsZero() {
t := s.TownTourEndsAt
ep.TownTourEndsAt = &t
}
if !s.TownWelcomeUntil.IsZero() {
t := s.TownWelcomeUntil
ep.TownWelcomeUntil = &t
}
if !s.TownServiceUntil.IsZero() {
t := s.TownServiceUntil
ep.TownServiceUntil = &t
}
if !s.TownRestUntil.IsZero() {
t := s.TownRestUntil
ep.TownRestUntil = &t
}
}
return ep
}
@ -1609,6 +1639,27 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
if ep.WanderNextAt != nil {
hm.Excursion.WanderNextAt = *ep.WanderNextAt
}
if ep.Kind == string(model.ExcursionKindTown) {
hm.Excursion.TownTourPhase = ep.TownTourPhase
hm.Excursion.TownTourNpcID = ep.TownTourNpcID
hm.Excursion.TownTourStandX = ep.TownTourStandX
hm.Excursion.TownTourStandY = ep.TownTourStandY
hm.Excursion.TownExitPending = ep.TownExitPending
hm.Excursion.TownTourDialogOpen = ep.TownTourDialogOpen
hm.Excursion.TownTourInteractionOpen = ep.TownTourInteractionOpen
if ep.TownTourEndsAt != nil {
hm.Excursion.TownTourEndsAt = *ep.TownTourEndsAt
}
if ep.TownWelcomeUntil != nil {
hm.Excursion.TownWelcomeUntil = *ep.TownWelcomeUntil
}
if ep.TownServiceUntil != nil {
hm.Excursion.TownServiceUntil = *ep.TownServiceUntil
}
if ep.TownRestUntil != nil {
hm.Excursion.TownRestUntil = *ep.TownRestUntil
}
}
}
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
@ -1676,26 +1727,6 @@ type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64)
// AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB).
type AfterTownEnterPersist func(hero *model.Hero)
// TownNPCOfflineInteractHook runs when the hero reaches a town NPC with no WS client (offline catch-up).
// Returns true if the hero stops and interacts (narration + timed logs); false if they walk past without stopping.
type TownNPCOfflineInteractHook func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, adventureLog AdventureLogWriter) bool
func townLastNpcLingerDuration() time.Duration {
ms := tuning.Get().TownLastNpcLingerMs
if ms <= 0 {
ms = tuning.DefaultValues().TownLastNpcLingerMs
}
return time.Duration(ms) * time.Millisecond
}
// scheduleLastNPCLingerFrom starts the “stand near last NPC” window when the NPC tour queue is empty.
func (hm *HeroMovement) scheduleLastNPCLingerFrom(now time.Time) {
if hm == nil || hm.State != model.StateInTown || len(hm.TownNPCQueue) != 0 {
return
}
hm.TownLastNPCLingerUntil = now.Add(townLastNpcLingerDuration())
}
func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
if log == nil || hm.TownVisitStartedAt.IsZero() || hm.TownNPCUILock {
return
@ -1716,28 +1747,6 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv
}
}
// skipTownNPCNarrationForDialogClose clears the per-NPC narration window after the player
// closes shop / healer / quest UI so the next movement tick can roll the next queued NPC or plaza rest.
func (hm *HeroMovement) skipTownNPCNarrationForDialogClose(now time.Time) {
if hm == nil || hm.State != model.StateInTown {
return
}
if hm.TownNPCWalkTargetID != 0 {
return
}
wasInVisit := !hm.TownVisitStartedAt.IsZero()
hm.TownVisitNPCName = ""
hm.TownVisitNPCKey = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.NextTownNPCRollAt = time.Time{}
hm.TownNPCUILock = false
if wasInVisit && len(hm.TownNPCQueue) == 0 {
hm.scheduleLastNPCLingerFrom(now)
}
}
// --- Excursion (mini-adventure) FSM helpers ---
func smoothstep(t float64) float64 {
@ -1954,7 +1963,7 @@ func randomDurationBetweenMs(minMs, maxMs int64) time.Duration {
// onEncounter is required for walking encounter rolls; if nil, encounters are not triggered.
// adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block).
// persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town.
// townNPCOfflineInteract, when sender is nil, decides offline buy/heal/quest vs walking past; nil uses legacy auto-sell-only behavior.
// townTourOffline, when sender is nil, resolves town NPC visits without UI during offline catch-up.
func ProcessSingleHeroMovementTick(
heroID int64,
hm *HeroMovement,
@ -1965,7 +1974,7 @@ func ProcessSingleHeroMovementTick(
onMerchantEncounter MerchantEncounterHook,
adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist,
townNPCOfflineInteract TownNPCOfflineInteractHook,
townTourOffline TownTourOfflineAtNPC,
) {
if graph == nil {
return
@ -2076,242 +2085,12 @@ func ProcessSingleHeroMovementTick(
}
case model.StateInTown:
cfg := tuning.Get()
dtTown := now.Sub(hm.LastMoveTick).Seconds()
if dtTown <= 0 {
dtTown = movementTickRate().Seconds()
}
hm.LastMoveTick = now
// While a town NPC dialog (shop / quests) is open, freeze narration deadlines by shifting anchors.
if hm.TownNPCUILock && dtTown > 0 {
shift := time.Duration(dtTown * float64(time.Second))
if !hm.TownVisitStartedAt.IsZero() {
hm.TownVisitStartedAt = hm.TownVisitStartedAt.Add(shift)
}
if !hm.NextTownNPCRollAt.IsZero() {
hm.NextTownNPCRollAt = hm.NextTownNPCRollAt.Add(shift)
}
if !hm.TownLastNPCLingerUntil.IsZero() {
hm.TownLastNPCLingerUntil = hm.TownLastNPCLingerUntil.Add(shift)
}
}
// --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) ---
if hm.TownCenterWalkActive {
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
arrived := hm.stepTowardWorldPoint(dtTown, hm.TownCenterWalkToX, hm.TownCenterWalkToY, walkSpeed)
if arrived {
hm.clearTownCenterWalk()
if sender != nil {
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.CurrentX, TargetY: hm.CurrentY,
Speed: 0, Heading: 0,
})
}
} else if sender != nil {
dx := hm.TownCenterWalkToX - hm.CurrentX
dy := hm.TownCenterWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.TownCenterWalkToX, TargetY: hm.TownCenterWalkToY,
Speed: walkSpeed, Heading: heading,
})
}
hm.SyncToHero()
return
}
// --- Sub-state: hero is walking toward an NPC inside the town (attractor stepping) ---
if hm.TownNPCWalkTargetID != 0 {
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
arrived := hm.stepTowardWorldPoint(dtTown, hm.TownNPCWalkToX, hm.TownNPCWalkToY, walkSpeed)
if arrived {
// Arrived at stand point (near NPC) — fire the visit event.
npcID := hm.TownNPCWalkTargetID
standX := hm.TownNPCWalkToX
standY := hm.TownNPCWalkToY
hm.clearNPCWalk()
if npc, ok := graph.NPCByID[npcID]; ok {
fullVisit := false
townNameKey := ""
if tt := graph.Towns[hm.CurrentTownID]; tt != nil {
townNameKey = tt.NameKey
}
if sender != nil {
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
NPCID: npc.ID, Name: npc.Name, NameKey: npc.NameKey, Type: npc.Type, TownID: hm.CurrentTownID,
TownNameKey: townNameKey,
WorldX: standX, WorldY: standY,
})
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.CurrentX, TargetY: hm.CurrentY,
Speed: 0, Heading: 0,
})
fullVisit = true
} else if townNPCOfflineInteract != nil {
fullVisit = townNPCOfflineInteract(heroID, hm, graph, npc, now, adventureLog)
} else {
fullVisit = true
}
if fullVisit {
hm.TownVisitNPCName = npc.Name
hm.TownVisitNPCKey = npc.NameKey
hm.TownVisitNPCType = npc.Type
hm.TownVisitStartedAt = now
hm.TownVisitLogsEmitted = 0
legacyMerchantSell := npc.Type == "merchant" && (sender != nil || townNPCOfflineInteract == nil)
if legacyMerchantSell {
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
}
soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
if soldItems > 0 && adventureLog != nil {
adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
}
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
} else {
if adventureLog != nil {
adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseNPCSkippedVisit,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
}
} else {
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
}
} else if sender != nil {
dx := hm.TownNPCWalkToX - hm.CurrentX
dy := hm.TownNPCWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.TownNPCWalkToX, TargetY: hm.TownNPCWalkToY,
Speed: walkSpeed, Heading: heading,
})
}
hm.SyncToHero()
if hm.Excursion.Kind == model.ExcursionKindTown {
processTownTourMovement(heroID, hm, graph, now, sender, adventureLog, townTourOffline)
return
}
// NPC visit pause ended: clear visit log state before the next roll.
if !hm.TownNPCUILock && !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
hm.TownVisitNPCName = ""
hm.TownVisitNPCKey = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
if len(hm.TownNPCQueue) == 0 {
hm.scheduleLastNPCLingerFrom(now)
}
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
if len(hm.TownNPCQueue) == 0 && hm.TownNPCWalkTargetID == 0 {
town := graph.Towns[hm.CurrentTownID]
if town == nil {
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
if route := hm.RoutePayload(); route != nil {
sender.SendToHero(heroID, "route_assigned", route)
}
}
return
}
// After the last NPC: stay at the stand point until linger ends and dialog is not open.
if !hm.TownLastNPCLingerUntil.IsZero() {
if hm.TownNPCUILock || now.Before(hm.TownLastNPCLingerUntil) {
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.CurrentX, TargetY: hm.CurrentY,
Speed: 0, Heading: 0,
})
}
hm.SyncToHero()
return
}
hm.TownLastNPCLingerUntil = time.Time{}
}
cx, cy := town.WorldX, town.WorldY
const plazaEps = 0.55
dPlaza := math.Hypot(hm.CurrentX-cx, hm.CurrentY-cy)
if dPlaza > plazaEps {
dx := cx - hm.CurrentX
dy := cy - hm.CurrentY
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
hm.TownCenterWalkToX = cx
hm.TownCenterWalkToY = cy
hm.TownCenterWalkActive = true
if sender != nil {
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: cx, TargetY: cy,
Speed: walkSpeed, Heading: heading,
})
}
hm.SyncToHero()
return
}
if hm.TownLeaveAt.IsZero() {
restCh := cfg.TownAfterNPCRestChance
if restCh <= 0 {
restCh = tuning.DefaultValues().TownAfterNPCRestChance
}
if restCh > 1 {
restCh = 1
}
if rand.Float64() < restCh {
hm.TownPlazaHealActive = true
hm.TownLeaveAt = now.Add(randomRestDuration())
} else {
hm.TownPlazaHealActive = false
hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
}
}
if hm.TownPlazaHealActive {
hm.applyTownRestHeal(dtTown)
}
if now.Before(hm.TownLeaveAt) {
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.SyncToHero()
return
}
hm.TownLeaveAt = time.Time{}
hm.TownPlazaHealActive = false
// Legacy in-town row without town excursion: force exit.
if graph != nil {
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
@ -2320,63 +2099,8 @@ func ProcessSingleHeroMovementTick(
sender.SendToHero(heroID, "route_assigned", route)
}
}
return
}
if now.Before(hm.NextTownNPCRollAt) {
hm.SyncToHero()
return
}
if rand.Float64() >= cfg.TownNPCVisitChance {
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
hm.SyncToHero()
return
}
approachCh := cfg.TownNPCApproachChance
if approachCh <= 0 {
approachCh = tuning.DefaultValues().TownNPCApproachChance
}
if approachCh > 1 {
approachCh = 1
}
if rand.Float64() >= approachCh {
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
hm.SyncToHero()
return
}
npcID := hm.TownNPCQueue[0]
hm.TownNPCQueue = hm.TownNPCQueue[1:]
if npc, ok := graph.NPCByID[npcID]; ok {
npcWX, npcWY, posOk := graph.NPCWorldPos(npcID, hm.CurrentTownID)
if !posOk {
if town := graph.Towns[hm.CurrentTownID]; town != nil {
npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY
}
}
standoff := cfg.TownNPCStandoffWorld
if standoff <= 0 {
standoff = tuning.DefaultValues().TownNPCStandoffWorld
}
toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff)
dx := toX - hm.CurrentX
dy := toY - hm.CurrentY
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
hm.TownNPCWalkTargetID = npcID
hm.TownNPCWalkToX = toX
hm.TownNPCWalkToY = toY
if sender != nil {
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: toX, TargetY: toY,
Speed: walkSpeed, Heading: heading,
})
}
}
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
hm.SyncToHero()
return
case model.StateWalking:
cfg := tuning.Get()

@ -206,7 +206,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
const maxOfflineMovementSteps = 200000
step := 0
offlineNPC := s.offlineTownNPCInteractHook(ctx)
offlineTownTour := s.offlineTownTourAtNPC(ctx)
for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps {
step++
next := hm.LastMoveTick.Add(movementTickRate())
@ -226,7 +226,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
adventureLog := func(heroID int64, line model.AdventureLogLine) {
s.addLog(ctx, heroID, line)
}
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineNPC)
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineTownTour)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break
}
@ -252,9 +252,9 @@ func (s *OfflineSimulator) SimulateHeroAt(ctx context.Context, hero *model.Hero,
return s.simulateHeroTick(ctx, hero, now, persist)
}
func (s *OfflineSimulator) offlineTownNPCInteractHook(ctx context.Context) TownNPCOfflineInteractHook {
return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
return s.applyOfflineTownNPCVisit(ctx, heroID, hm, graph, npc, now, al)
func (s *OfflineSimulator) offlineTownTourAtNPC(ctx context.Context) TownTourOfflineAtNPC {
return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
s.applyOfflineTownTourNPCVisit(ctx, heroID, hm, graph, npc, now, al)
}
}
@ -281,74 +281,102 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
}
}
// applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI).
// With no live WebSocket, service use (gear, potion, heal, quest accept) each fires independently with probability 0.2 when affordable.
func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
_ = now
cfg := tuning.Get()
inter := cfg.TownNPCInteractChance
if inter <= 0 {
inter = tuning.DefaultValues().TownNPCInteractChance
}
if inter > 1 {
inter = 1
}
if rand.Float64() >= inter {
return false
}
// applyOfflineTownTourNPCVisit resolves one town-tour NPC stop without UI: quest → merchant upgrade → healer heal → potion → fallbacks.
func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
h := hm.Hero
if h == nil {
return false
return
}
var town *model.Town
if graph != nil {
town = graph.Towns[hm.CurrentTownID]
}
townLv := TownEffectiveLevel(town)
const offlineServiceChance = 0.2
cfg := tuning.Get()
switch npc.Type {
case "merchant":
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
tryQuest := func() bool {
if npc.Type != "quest_giver" || s.questStore == nil {
return false
}
soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
if soldItems > 0 && al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
hqs, err := s.questStore.ListHeroQuests(ctx, heroID)
if err != nil {
s.logger.Warn("offline town tour: list hero quests", "error", err)
return false
}
gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance {
h.Gold -= gearCost
drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now)
taken := make(map[int64]struct{}, len(hqs))
for _, hq := range hqs {
taken[hq.QuestID] = struct{}{}
}
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv)
if err != nil {
s.logger.Warn("offline town tour: list quests by npc", "error", err)
return false
}
for _, q := range offered {
if _, ok := taken[q.ID]; ok {
continue
}
ok, err := s.questStore.TryAcceptQuest(ctx, heroID, q.ID)
if err != nil {
h.Gold += gearCost
s.logger.Warn("offline town merchant gear", "hero_id", heroID, "error", err)
} else if al != nil && drop != nil {
townKey := ""
if town != nil {
townKey = town.NameKey
s.logger.Warn("offline town tour: try accept quest", "error", err)
return false
}
if ok && al != nil {
qk := q.QuestKey
if qk == "" {
qk = fmt.Sprintf("quest.%d", q.ID)
}
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseBoughtGearTownMerchant,
Args: map[string]any{
"npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
"rarity": string(drop.Rarity), "itemId": drop.ItemID,
},
Code: model.LogPhraseQuestAccepted,
Args: map[string]any{"questKey": qk},
},
})
}
return ok
}
case "healer":
_, healCost := tuning.EffectiveNPCShopCosts()
potionCost, _ := tuning.EffectiveNPCShopCosts()
if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance {
return false
}
if tryQuest() {
return
}
if npc.Type == "merchant" {
gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost {
items := RollTownMerchantStockItems(townLv, 1)
if len(items) > 0 && TownMerchantRollIsUpgrade(h, items[0], now) {
h.Gold -= gearCost
drop, err := ApplyPreparedTownMerchantPurchase(ctx, s.gearStore, h, items[0], now)
if err != nil {
h.Gold += gearCost
s.logger.Warn("offline town tour merchant gear", "hero_id", heroID, "error", err)
} else if al != nil && drop != nil {
townKey := ""
if town != nil {
townKey = town.NameKey
}
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseBoughtGearTownMerchant,
Args: map[string]any{
"npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
"rarity": string(drop.Rarity), "itemId": drop.ItemID,
},
},
})
}
return
}
}
}
_, healCost := tuning.EffectiveNPCShopCosts()
potionCost, _ := tuning.EffectiveNPCShopCosts()
if npc.Type == "healer" && h.MaxHP > 0 {
hpFrac := float64(h.HP) / float64(h.MaxHP)
if hpFrac < 0.5 && healCost > 0 && h.Gold >= healCost && h.HP < h.MaxHP {
h.Gold -= healCost
h.HP = h.MaxHP
if al != nil {
@ -359,8 +387,9 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
},
})
}
return
}
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < offlineServiceChance {
if potionCost > 0 && h.Gold >= potionCost {
h.Gold -= potionCost
h.Potions++
if al != nil {
@ -371,75 +400,35 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
},
})
}
return
}
case "quest_giver":
if s.questStore == nil {
return true
}
hqs, err := s.questStore.ListHeroQuests(ctx, heroID)
if err != nil {
s.logger.Warn("offline town npc: list hero quests", "error", err)
return true
}
taken := make(map[int64]struct{}, len(hqs))
for _, hq := range hqs {
taken[hq.QuestID] = struct{}{}
}
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv)
if err != nil {
s.logger.Warn("offline town npc: list quests by npc", "error", err)
return true
}
var candidates []model.Quest
for _, q := range offered {
if _, ok := taken[q.ID]; !ok {
candidates = append(candidates, q)
}
}
if len(candidates) == 0 {
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
return true
}
if rand.Float64() >= offlineServiceChance {
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
return true
}
pick := candidates[rand.Intn(len(candidates))]
ok, err := s.questStore.TryAcceptQuest(ctx, heroID, pick.ID)
if err != nil {
s.logger.Warn("offline town npc: try accept quest", "error", err)
return true
}
if npc.Type == "merchant" {
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
}
if ok && al != nil {
qk := pick.QuestKey
if qk == "" {
qk = fmt.Sprintf("quest.%d", pick.ID)
}
soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
if soldItems > 0 && al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestAccepted,
Args: map[string]any{"questKey": qk},
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
}
default:
// Other NPC types: treat as a social stop only.
return
}
if npc.Type == "quest_giver" && al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
return true
}
// addLog is a fire-and-forget helper that writes an adventure log entry.

@ -152,3 +152,17 @@ func ApplyTownMerchantGearPurchase(ctx context.Context, gs *storage.GearStore, h
}
return ApplyPreparedTownMerchantPurchase(ctx, gs, hero, items[0], now)
}
// TownMerchantRollIsUpgrade returns true if equipping the rolled item (hypothetically) raises CombatRatingAt.
func TownMerchantRollIsUpgrade(hero *model.Hero, item *model.GearItem, now time.Time) bool {
if hero == nil || item == nil {
return false
}
hero.EnsureGearMap()
before := hero.CombatRatingAt(now)
old := hero.Gear[item.Slot]
hero.Gear[item.Slot] = item
after := hero.CombatRatingAt(now)
hero.Gear[item.Slot] = old
return after > before
}

@ -0,0 +1,562 @@
package game
import (
"errors"
"math"
"math/rand"
"strconv"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
// TownTourOfflineAtNPC resolves a town NPC visit without UI (offline catch-up).
type TownTourOfflineAtNPC func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, adventureLog AdventureLogWriter)
func scheduleTownTourWanderRetarget(hm *HeroMovement, now time.Time) {
cfg := tuning.Get()
minMs := cfg.TownTourWanderRetargetMinMs
maxMs := cfg.TownTourWanderRetargetMaxMs
if minMs <= 0 {
minMs = tuning.DefaultValues().TownTourWanderRetargetMinMs
}
if maxMs <= 0 {
maxMs = tuning.DefaultValues().TownTourWanderRetargetMaxMs
}
hm.Excursion.WanderNextAt = now.Add(randomDurationBetweenMs(minMs, maxMs))
}
// beginTownTourExcursion starts attractor-based wandering in the current town (StateInTown).
func beginTownTourExcursion(hm *HeroMovement, now time.Time, graph *RoadGraph) {
if hm == nil || graph == nil {
return
}
clearLegacyTownNPCState(hm)
dur := randomRestDuration()
hm.Excursion = model.ExcursionSession{
Kind: model.ExcursionKindTown,
Phase: model.ExcursionWild,
StartedAt: now,
TownTourPhase: string(model.TownTourPhaseWander),
TownTourEndsAt: now.Add(dur),
}
scheduleTownTourWanderRetarget(hm, now)
pickTownTourWanderAttractor(hm, graph, now)
}
func clearLegacyTownNPCState(hm *HeroMovement) {
if hm == nil {
return
}
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.TownNPCWalkTargetID = 0
hm.TownNPCWalkToX = 0
hm.TownNPCWalkToY = 0
hm.TownCenterWalkActive = false
hm.TownCenterWalkToX = 0
hm.TownCenterWalkToY = 0
hm.TownPlazaHealActive = false
hm.TownLeaveAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCKey = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownNPCUILock = false
}
func clearTownVisitLogFields(hm *HeroMovement) {
if hm == nil {
return
}
hm.TownVisitNPCName = ""
hm.TownVisitNPCKey = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
}
func transitionTownTourToWander(hm *HeroMovement, graph *RoadGraph, now time.Time) {
ex := &hm.Excursion
ex.TownTourPhase = string(model.TownTourPhaseWander)
ex.TownTourNpcID = 0
ex.TownTourStandX = 0
ex.TownTourStandY = 0
ex.TownWelcomeUntil = time.Time{}
ex.TownServiceUntil = time.Time{}
ex.TownTourDialogOpen = false
ex.TownTourInteractionOpen = false
clearTownVisitLogFields(hm)
scheduleTownTourWanderRetarget(hm, now)
pickTownTourWanderAttractor(hm, graph, now)
}
// pickTownTourWanderAttractor chooses the next wander target: random point in town or stand near an NPC.
func pickTownTourWanderAttractor(hm *HeroMovement, graph *RoadGraph, now time.Time) {
if hm == nil || graph == nil {
return
}
ex := &hm.Excursion
if ex.TownExitPending {
return
}
town := graph.Towns[hm.CurrentTownID]
if town == nil {
return
}
cfg := tuning.Get()
npcs := graph.TownNPCs[hm.CurrentTownID]
pNpc := cfg.TownTourNpcAttractorChance
if pNpc <= 0 {
pNpc = tuning.DefaultValues().TownTourNpcAttractorChance
}
if pNpc > 1 {
pNpc = 1
}
if len(npcs) > 0 && rand.Float64() < pNpc {
npc := npcs[rand.Intn(len(npcs))]
npcWX, npcWY, posOk := graph.NPCWorldPos(npc.ID, hm.CurrentTownID)
if !posOk {
npcWX = town.WorldX + npc.OffsetX
npcWY = town.WorldY + npc.OffsetY
}
standoff := cfg.TownNPCStandoffWorld
if standoff <= 0 {
standoff = tuning.DefaultValues().TownNPCStandoffWorld
}
toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff)
ex.TownTourPhase = string(model.TownTourPhaseNpcApproach)
ex.TownTourNpcID = npc.ID
ex.TownTourStandX = toX
ex.TownTourStandY = toY
ex.AttractorSet = false
return
}
// Random point inside town circle (keep margin from edge).
cx, cy := town.WorldX, town.WorldY
radius := town.Radius
if radius < 1 {
radius = 8
}
margin := radius * 0.12
maxR := radius - margin
if maxR < margin {
maxR = radius * 0.5
}
for attempt := 0; attempt < 24; attempt++ {
theta := rand.Float64() * 2 * math.Pi
rd := margin + rand.Float64()*math.Max(0.01, maxR-margin)
px := cx + math.Cos(theta)*rd
py := cy + math.Sin(theta)*rd
if graph.HeroInTownAt(px, py) {
ex.AttractorX = px
ex.AttractorY = py
ex.AttractorSet = true
ex.TownTourPhase = string(model.TownTourPhaseWander)
ex.TownTourNpcID = 0
return
}
}
ex.AttractorX = cx
ex.AttractorY = cy
ex.AttractorSet = true
ex.TownTourPhase = string(model.TownTourPhaseWander)
ex.TownTourNpcID = 0
}
// AdminTownTourApproachNPC forces npc_approach toward npcID in the hero's current town (admin only).
func (hm *HeroMovement) AdminTownTourApproachNPC(graph *RoadGraph, npcID int64, now time.Time) error {
if hm == nil || graph == nil {
return errors.New("nil movement or graph")
}
if hm.Excursion.Kind != model.ExcursionKindTown {
return errors.New("hero is not on town tour excursion")
}
if hm.State != model.StateInTown {
return errors.New("hero must be in town")
}
npc, ok := graph.NPCByID[npcID]
if !ok {
return errors.New("npc not found in world graph")
}
found := false
for _, n := range graph.TownNPCs[hm.CurrentTownID] {
if n.ID == npcID {
found = true
break
}
}
if !found {
return errors.New("npc is not in hero's current town")
}
town := graph.Towns[hm.CurrentTownID]
if town == nil {
return errors.New("town not found")
}
cfg := tuning.Get()
npcWX, npcWY, posOk := graph.NPCWorldPos(npc.ID, hm.CurrentTownID)
if !posOk {
npcWX = town.WorldX + npc.OffsetX
npcWY = town.WorldY + npc.OffsetY
}
standoff := cfg.TownNPCStandoffWorld
if standoff <= 0 {
standoff = tuning.DefaultValues().TownNPCStandoffWorld
}
ex := &hm.Excursion
toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff)
ex.TownTourPhase = string(model.TownTourPhaseNpcApproach)
ex.TownTourNpcID = npc.ID
ex.TownTourStandX = toX
ex.TownTourStandY = toY
ex.AttractorSet = false
ex.TownWelcomeUntil = time.Time{}
ex.TownServiceUntil = time.Time{}
ex.TownTourDialogOpen = false
ex.TownTourInteractionOpen = false
hm.TownNPCUILock = false
hm.sentTownTourWireSig = ""
return nil
}
// NotifyTownTourClients pushes town_tour_phase, hero_state, and hero_move after an out-of-tick town tour change.
func NotifyTownTourClients(sender MessageSender, heroID int64, hm *HeroMovement, graph *RoadGraph, now time.Time) {
if sender == nil || hm == nil || graph == nil || hm.Excursion.Kind != model.ExcursionKindTown {
return
}
hm.sentTownTourWireSig = ""
sendTownTourUpdate(sender, heroID, hm, graph)
h := hm.Hero
if h != nil {
h.EnsureGearMap()
h.RefreshDerivedCombatStats(now)
sender.SendToHero(heroID, "hero_state", h)
}
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
func townTourWireSig(hm *HeroMovement) string {
if hm == nil {
return ""
}
ex := hm.Excursion
return ex.TownTourPhase + ":" + strconv.FormatInt(ex.TownTourNpcID, 10) + ":" + strconv.FormatBool(ex.TownExitPending)
}
func sendTownTourUpdate(sender MessageSender, heroID int64, hm *HeroMovement, graph *RoadGraph) {
if sender == nil || hm == nil || graph == nil {
return
}
ex := hm.Excursion
town := graph.Towns[hm.CurrentTownID]
var townKey string
if town != nil {
townKey = town.NameKey
}
var npcID int64
var name, nameKey, npcType string
var wx, wy float64
if ex.TownTourNpcID != 0 {
if npc, ok := graph.NPCByID[ex.TownTourNpcID]; ok {
npcID = npc.ID
name = npc.Name
nameKey = npc.NameKey
npcType = npc.Type
if x, y, ok2 := graph.NPCWorldPos(npc.ID, hm.CurrentTownID); ok2 {
wx, wy = x, y
} else if town != nil {
wx = town.WorldX + npc.OffsetX
wy = town.WorldY + npc.OffsetY
}
}
}
payload := model.TownTourPhasePayload{
Phase: ex.TownTourPhase,
TownID: hm.CurrentTownID,
TownNameKey: townKey,
NpcID: npcID,
NpcName: name,
NpcNameKey: nameKey,
NpcType: npcType,
WorldX: wx,
WorldY: wy,
ExitPending: ex.TownExitPending,
}
sender.SendToHero(heroID, "town_tour_phase", payload)
}
func processTownTourMovement(
heroID int64,
hm *HeroMovement,
graph *RoadGraph,
now time.Time,
sender MessageSender,
adventureLog AdventureLogWriter,
townTourOffline TownTourOfflineAtNPC,
) {
if hm == nil || graph == nil {
return
}
ex := &hm.Excursion
cfg := tuning.Get()
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = movementTickRate().Seconds()
}
hm.LastMoveTick = now
hm.refreshSpeed(now)
if !now.Before(ex.TownTourEndsAt) {
ex.TownExitPending = true
}
uiOpen := ex.TownTourDialogOpen || ex.TownTourInteractionOpen
if uiOpen && dt > 0 {
shift := time.Duration(dt * float64(time.Second))
switch model.TownTourPhase(ex.TownTourPhase) {
case model.TownTourPhaseNpcWelcome:
if !ex.TownWelcomeUntil.IsZero() {
ex.TownWelcomeUntil = ex.TownWelcomeUntil.Add(shift)
}
case model.TownTourPhaseNpcService:
if !ex.TownServiceUntil.IsZero() {
ex.TownServiceUntil = ex.TownServiceUntil.Add(shift)
}
case model.TownTourPhaseRest:
if !ex.TownRestUntil.IsZero() {
ex.TownRestUntil = ex.TownRestUntil.Add(shift)
}
ex.TownTourEndsAt = ex.TownTourEndsAt.Add(shift)
}
}
hm.TownNPCUILock = uiOpen
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
switch model.TownTourPhase(ex.TownTourPhase) {
case model.TownTourPhaseWander:
if !ex.AttractorSet {
// Defensive: pick a wander target.
pickTownTourWanderAttractor(hm, graph, now)
}
arrived := hm.stepTowardAttractor(now, dt)
if !arrived {
break
}
// At wander attractor.
if ex.TownExitPending {
hm.LeaveTown(graph, now)
hm.Excursion = model.ExcursionSession{}
hm.sentTownTourWireSig = ""
if sender != nil {
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
if route := hm.RoutePayload(); route != nil {
sender.SendToHero(heroID, "route_assigned", route)
}
}
return
}
if now.Before(ex.WanderNextAt) {
break
}
hpFrac := 1.0
if hm.Hero != nil && hm.Hero.MaxHP > 0 {
hpFrac = float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
}
th := cfg.TownRestHpThreshold
if th <= 0 {
th = tuning.DefaultValues().TownRestHpThreshold
}
rch := cfg.TownRestChance
if rch <= 0 {
rch = tuning.DefaultValues().TownRestChance
}
if rch > 1 {
rch = 1
}
if hpFrac < th && rand.Float64() < rch && !ex.TownExitPending {
minR := cfg.TownTourRestMinMs
maxR := cfg.TownTourRestMaxMs
if minR <= 0 {
minR = tuning.DefaultValues().TownTourRestMinMs
}
if maxR <= 0 {
maxR = tuning.DefaultValues().TownTourRestMaxMs
}
ex.TownTourPhase = string(model.TownTourPhaseRest)
ex.TownRestUntil = now.Add(randomDurationBetweenMs(minR, maxR))
ex.AttractorSet = false
break
}
scheduleTownTourWanderRetarget(hm, now)
pickTownTourWanderAttractor(hm, graph, now)
case model.TownTourPhaseNpcApproach:
arrived := hm.stepTowardWorldPoint(dt, ex.TownTourStandX, ex.TownTourStandY, walkSpeed)
if !arrived {
break
}
npc, ok := graph.NPCByID[ex.TownTourNpcID]
if !ok {
transitionTownTourToWander(hm, graph, now)
break
}
if sender != nil {
// Online: welcome + dialog.
ex.TownTourPhase = string(model.TownTourPhaseNpcWelcome)
welcomeMs := cfg.TownWelcomeDurationMs
if welcomeMs <= 0 {
welcomeMs = tuning.DefaultValues().TownWelcomeDurationMs
}
ex.TownWelcomeUntil = now.Add(time.Duration(welcomeMs) * time.Millisecond)
hm.TownVisitNPCName = npc.Name
hm.TownVisitNPCKey = npc.NameKey
hm.TownVisitNPCType = npc.Type
hm.TownVisitStartedAt = now
hm.TownVisitLogsEmitted = 0
townNameKey := ""
if tt := graph.Towns[hm.CurrentTownID]; tt != nil {
townNameKey = tt.NameKey
}
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: ex.TownTourStandX, WorldY: ex.TownTourStandY,
})
legacyMerchantSell := npc.Type == "merchant"
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)
} else {
if townTourOffline != nil {
townTourOffline(heroID, hm, graph, npc, now, adventureLog)
}
transitionTownTourToWander(hm, graph, now)
}
case model.TownTourPhaseNpcWelcome:
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
if !ex.TownWelcomeUntil.IsZero() && !now.Before(ex.TownWelcomeUntil) && !ex.TownTourDialogOpen {
transitionTownTourToWander(hm, graph, now)
break
}
case model.TownTourPhaseNpcService:
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
svcMs := cfg.TownServiceMaxMs
if svcMs <= 0 {
svcMs = tuning.DefaultValues().TownServiceMaxMs
}
if !ex.TownServiceUntil.IsZero() && !now.Before(ex.TownServiceUntil) {
if sender != nil {
sender.SendToHero(heroID, "town_tour_service_end", model.TownTourServiceEndPayload{Reason: "timeout"})
}
transitionTownTourToWander(hm, graph, now)
break
}
case model.TownTourPhaseRest:
hm.applyTownRestHeal(dt)
if !ex.TownRestUntil.IsZero() && now.After(ex.TownRestUntil) {
ex.TownTourPhase = string(model.TownTourPhaseWander)
ex.TownRestUntil = time.Time{}
scheduleTownTourWanderRetarget(hm, now)
pickTownTourWanderAttractor(hm, graph, now)
}
}
sig := townTourWireSig(hm)
if sender != nil && hm.Excursion.Kind == model.ExcursionKindTown {
if sig != hm.sentTownTourWireSig {
hm.sentTownTourWireSig = sig
sendTownTourUpdate(sender, heroID, hm, graph)
}
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.SyncToHero()
}
// Town tour client command handlers (engine calls under lock).
func (hm *HeroMovement) townTourNPCDialogClosed(now time.Time, graph *RoadGraph) {
if hm == nil || graph == nil {
return
}
if hm.Excursion.Kind != model.ExcursionKindTown {
return
}
ex := &hm.Excursion
ex.TownTourDialogOpen = false
hm.TownNPCUILock = ex.TownTourDialogOpen || ex.TownTourInteractionOpen
switch model.TownTourPhase(ex.TownTourPhase) {
case model.TownTourPhaseNpcWelcome:
transitionTownTourToWander(hm, graph, now)
case model.TownTourPhaseNpcService:
if !ex.TownTourInteractionOpen {
transitionTownTourToWander(hm, graph, now)
}
}
}
func (hm *HeroMovement) townTourNPCInteractionOpened(now time.Time, graph *RoadGraph) {
if hm == nil || graph == nil {
return
}
if hm.Excursion.Kind != model.ExcursionKindTown {
return
}
ex := &hm.Excursion
ex.TownTourInteractionOpen = true
hm.TownNPCUILock = ex.TownTourDialogOpen || ex.TownTourInteractionOpen
if model.TownTourPhase(ex.TownTourPhase) == model.TownTourPhaseNpcWelcome {
ex.TownTourPhase = string(model.TownTourPhaseNpcService)
svcMs := tuning.Get().TownServiceMaxMs
if svcMs <= 0 {
svcMs = tuning.DefaultValues().TownServiceMaxMs
}
ex.TownServiceUntil = now.Add(time.Duration(svcMs) * time.Millisecond)
}
}
func (hm *HeroMovement) townTourNPCInteractionClosed(now time.Time, graph *RoadGraph) {
if hm == nil || graph == nil {
return
}
if hm.Excursion.Kind != model.ExcursionKindTown {
return
}
ex := &hm.Excursion
ex.TownTourInteractionOpen = false
hm.TownNPCUILock = ex.TownTourDialogOpen || ex.TownTourInteractionOpen
if model.TownTourPhase(ex.TownTourPhase) == model.TownTourPhaseNpcService {
transitionTownTourToWander(hm, graph, now)
}
}
func (hm *HeroMovement) townTourSetDialogOpen(open bool) {
if hm == nil || hm.Excursion.Kind != model.ExcursionKindTown {
return
}
hm.Excursion.TownTourDialogOpen = open
hm.TownNPCUILock = hm.Excursion.TownTourDialogOpen || hm.Excursion.TownTourInteractionOpen
}

@ -0,0 +1,119 @@
package game
import (
"math/rand"
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
func testGraphTownTour(t *testing.T) *RoadGraph {
t.Helper()
g := testGraph()
g.Towns[1].Radius = 35
npc := TownNPC{ID: 101, Name: "Merchant", NameKey: "npc.merchant.test", Type: "merchant", OffsetX: 2, OffsetY: 1}
g.TownNPCs[1] = []TownNPC{npc}
g.NPCByID[101] = npc
return g
}
func heroInTown(id int64, townID int64) *model.Hero {
return &model.Hero{
ID: id, Level: 5, HP: 900, MaxHP: 1000,
Attack: 50, Defense: 30, Speed: 1.0,
Strength: 10, Constitution: 10, Agility: 10, Luck: 5,
State: model.StateInTown,
CurrentTownID: &townID,
PositionX: 1, PositionY: 1,
}
}
func TestTownTour_WelcomeTimeoutReturnsToWander(t *testing.T) {
graph := testGraphTownTour(t)
hero := heroInTown(1, 1)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.CurrentTownID = 1
hm.CurrentX = 1
hm.CurrentY = 1
hm.LastMoveTick = now
hm.Excursion = model.ExcursionSession{
Kind: model.ExcursionKindTown,
Phase: model.ExcursionWild,
TownTourPhase: string(model.TownTourPhaseNpcWelcome),
TownWelcomeUntil: now.Add(-time.Second),
TownTourNpcID: 101,
TownTourStandX: 3,
TownTourStandY: 2,
AttractorSet: true,
AttractorX: 3,
AttractorY: 2,
}
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(50*time.Millisecond), nil, nil, nil, nil, nil, nil)
if hm.Excursion.TownTourPhase != string(model.TownTourPhaseWander) {
t.Fatalf("expected wander after welcome timeout, got %q", hm.Excursion.TownTourPhase)
}
}
func TestTownTour_DialogClosedFromWelcomeLeavesWelcome(t *testing.T) {
graph := testGraphTownTour(t)
hero := heroInTown(1, 1)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.CurrentTownID = 1
hm.Excursion = model.ExcursionSession{
Kind: model.ExcursionKindTown,
Phase: model.ExcursionWild,
TownTourPhase: string(model.TownTourPhaseNpcWelcome),
TownWelcomeUntil: now.Add(time.Hour),
TownTourNpcID: 101,
}
hm.townTourNPCDialogClosed(now, graph)
if model.TownTourPhase(hm.Excursion.TownTourPhase) == model.TownTourPhaseNpcWelcome {
t.Fatal("still in npc_welcome after dialog closed")
}
if !hm.Excursion.TownWelcomeUntil.IsZero() {
t.Fatal("expected TownWelcomeUntil cleared")
}
}
// TestTownTour_DefaultPNpc_AtLeastOneNpcOpportunity uses default tuning to estimate P(≥1 NPC attractor pick)
// over a synthetic town stay (retarget cadence vs tour length). Target from design: ≥ 0.6.
func TestTownTour_DefaultPNpc_AtLeastOneNpcOpportunity(t *testing.T) {
cfg := tuning.DefaultValues()
pNpc := cfg.TownTourNpcAttractorChance
minRT := cfg.TownTourWanderRetargetMinMs
maxRT := cfg.TownTourWanderRetargetMaxMs
minStay := cfg.TownRestMinMs
maxStay := cfg.TownRestMaxMs
if minRT <= 0 || maxRT < minRT || minStay <= 0 || maxStay < minStay {
t.Fatal("invalid default town tour / rest durations")
}
const trials = 8000
rng := rand.New(rand.NewSource(42))
hits := 0
for i := 0; i < trials; i++ {
stayMs := minStay + rng.Int63n(maxStay-minStay+1)
anyNpc := false
for elapsed := int64(0); elapsed < stayMs; {
if rng.Float64() < pNpc {
anyNpc = true
break
}
step := minRT + rng.Int63n(maxRT-minRT+1)
if step < 1 {
step = 1
}
elapsed += step
}
if anyNpc {
hits++
}
}
rate := float64(hits) / float64(trials)
if rate < 0.6 {
t.Fatalf("Monte Carlo P(≥1 NPC retarget)=%.3f with defaults; want >= 0.6 (townTourNpcAttractorChance=%.3f)", rate, pNpc)
}
}

@ -69,19 +69,37 @@ type heroSummary struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// adminTownTourLiveJSON is a snapshot of ExcursionKindTown for the admin UI.
type adminTownTourLiveJSON struct {
Phase string `json:"phase,omitempty"`
NpcID int64 `json:"npcId,omitempty"`
TownTourEndsAt *time.Time `json:"townTourEndsAt,omitempty"`
WelcomeUntil *time.Time `json:"townWelcomeUntil,omitempty"`
ServiceUntil *time.Time `json:"townServiceUntil,omitempty"`
RestUntil *time.Time `json:"townRestUntil,omitempty"`
WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
ExitPending bool `json:"townExitPending,omitempty"`
DialogOpen bool `json:"townTourDialogOpen,omitempty"`
InteractionOpen bool `json:"townTourInteractionOpen,omitempty"`
StandX float64 `json:"townTourStandX,omitempty"`
StandY float64 `json:"townTourStandY,omitempty"`
}
// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct {
Online bool `json:"online"`
MoveState string `json:"moveState,omitempty"`
RestUntil *time.Time `json:"restUntil,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
CurrentTownID int64 `json:"currentTownId,omitempty"`
DestinationTownID int64 `json:"destinationTownId,omitempty"`
WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
ExcursionPhase string `json:"excursionPhase,omitempty"`
ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"`
ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,omitempty"`
Online bool `json:"online"`
MoveState string `json:"moveState,omitempty"`
RestUntil *time.Time `json:"restUntil,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
CurrentTownID int64 `json:"currentTownId,omitempty"`
DestinationTownID int64 `json:"destinationTownId,omitempty"`
WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
ExcursionKind string `json:"excursionKind,omitempty"`
ExcursionPhase string `json:"excursionPhase,omitempty"`
ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"`
ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,omitempty"`
TownTour *adminTownTourLiveJSON `json:"townTour,omitempty"`
}
// adminHeroDetailResponse is the full admin JSON for one hero: base hero + persisted town_pause + live movement snapshot.
@ -157,6 +175,7 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
s.WanderingMerchantDeadline = &t
}
if hm.Excursion.Active() {
s.ExcursionKind = string(hm.Excursion.Kind)
s.ExcursionPhase = string(hm.Excursion.Phase)
if !hm.Excursion.WildUntil.IsZero() {
t := hm.Excursion.WildUntil
@ -167,6 +186,39 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
s.ExcursionReturnUntil = &t
}
}
if hm.Excursion.Kind == model.ExcursionKindTown {
ex := hm.Excursion
tt := &adminTownTourLiveJSON{
Phase: ex.TownTourPhase,
NpcID: ex.TownTourNpcID,
ExitPending: ex.TownExitPending,
DialogOpen: ex.TownTourDialogOpen,
InteractionOpen: ex.TownTourInteractionOpen,
StandX: ex.TownTourStandX,
StandY: ex.TownTourStandY,
}
if !ex.TownTourEndsAt.IsZero() {
t := ex.TownTourEndsAt
tt.TownTourEndsAt = &t
}
if !ex.TownWelcomeUntil.IsZero() {
t := ex.TownWelcomeUntil
tt.WelcomeUntil = &t
}
if !ex.TownServiceUntil.IsZero() {
t := ex.TownServiceUntil
tt.ServiceUntil = &t
}
if !ex.TownRestUntil.IsZero() {
t := ex.TownRestUntil
tt.RestUntil = &t
}
if !ex.WanderNextAt.IsZero() {
t := ex.WanderNextAt
tt.WanderNextAt = &t
}
s.TownTour = tt
}
return s
}
@ -2009,6 +2061,48 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) {
h.writeAdminHeroDetail(w, hero2)
}
// TownTourApproachNPC forces npc_approach toward a specific NPC during ExcursionKindTown (online heroes only).
// POST /admin/heroes/{heroId}/town-tour-approach-npc body: {"npcId":123}
func (h *AdminHandler) TownTourApproachNPC(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
var req struct {
NpcID int64 `json:"npcId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NpcID <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: need {\"npcId\": positive number}"})
return
}
if h.engine.GetMovements(heroID) == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be online (active WS movement session)"})
return
}
out, err := h.engine.ApplyAdminTownTourApproachNPC(heroID, req.NpcID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
out.RefreshDerivedCombatStats(time.Now())
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after town-tour-approach-npc", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.logger.Info("admin: town tour approach npc", "hero_id", heroID, "npc_id", req.NpcID)
heroAfter, err := h.store.GetByID(r.Context(), heroID)
if err != nil || heroAfter == nil {
h.writeAdminHeroDetail(w, out)
return
}
h.writeAdminHeroDetail(w, heroAfter)
}
// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online.
// POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {

@ -11,12 +11,15 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"golang.org/x/text/unicode/norm"
"github.com/denisovdennis/autohero/internal/changelog"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/profanity"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/version"
@ -1035,13 +1038,14 @@ type heroNameRequest struct {
Name string `json:"name"`
}
// isValidHeroName checks that a name is 2-16 chars, only latin/cyrillic letters and digits,
// no leading/trailing spaces.
// isValidHeroName checks that a name is 2-16 Unicode characters, only latin/cyrillic letters and digits,
// no leading/trailing spaces. Expects trimmed, NFC-normalized input (see SetHeroName).
func isValidHeroName(name string) bool {
if len(name) < 2 || len(name) > 16 {
n := utf8.RuneCountInString(name)
if n < 2 || n > 16 {
return false
}
if name[0] == ' ' || name[len(name)-1] == ' ' {
if strings.TrimSpace(name) != name {
return false
}
for _, r := range name {
@ -1079,12 +1083,20 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) {
return
}
req.Name = norm.NFC.String(strings.TrimSpace(req.Name))
if !isValidHeroName(req.Name) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid name: must be 2-16 characters, letters (latin/cyrillic) and digits only",
})
return
}
if profanity.HeroNameIsProfane(req.Name) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid name: inappropriate language",
})
return
}
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
if err != nil {

@ -0,0 +1,35 @@
package handler
import (
"strings"
"testing"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
)
func TestIsValidHeroName_NFCCombiningY(t *testing.T) {
// Й в NFD: и + U+0306; без NFC вторая руна не входит в блок кириллицы и имя отклонялось.
raw := "\u0438\u0306a"
nfc := norm.NFC.String(strings.TrimSpace(raw))
if nfc != "\u0439a" && utf8.RuneCountInString(nfc) != 2 {
t.Fatalf("unexpected NFC: %q (%d runes)", nfc, utf8.RuneCountInString(nfc))
}
if !isValidHeroName(nfc) {
t.Fatal("expected valid name with й (NFC)")
}
}
func TestIsValidHeroName_runeLengthNotBytes(t *testing.T) {
// 9 кириллических букв = 18 байт UTF-8, но 9 символов — должно быть допустимо.
name := "Абвгдейкz"
if utf8.RuneCountInString(name) != 9 {
t.Fatal("test setup")
}
if len(name) <= 16 {
t.Fatal("test needs >16 bytes with <=16 runes")
}
if !isValidHeroName(name) {
t.Fatal("expected valid: length limit is runes, not UTF-8 bytes")
}
}

@ -884,8 +884,7 @@ func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Open bool `json:"open"`
AdvanceTownVisit bool `json:"advanceTownVisit"`
Open bool `json:"open"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
@ -902,14 +901,10 @@ func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) {
return
}
if h.engine != nil {
if !req.Open && req.AdvanceTownVisit {
h.engine.SkipTownNPCNarrationAfterDialog(hero.ID)
h.engine.SetTownNPCUILock(hero.ID, req.Open)
if !req.Open {
h.engine.ClearMerchantStock(hero.ID)
} else {
h.engine.SetTownNPCUILock(hero.ID, req.Open)
if !req.Open {
h.engine.ClearMerchantStock(hero.ID)
}
h.engine.SkipTownNPCNarrationAfterDialog(hero.ID)
}
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})

@ -4,6 +4,7 @@ import "time"
// ExcursionPhase tracks where the hero is within a mini-adventure session.
// The lifecycle is: Out → Wild → Return → (back to road, phase cleared).
// For KindTown, Phase is usually ExcursionWild while using attractor movement during wander/rest.
type ExcursionPhase string
const (
@ -13,17 +14,29 @@ const (
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible)
)
// ExcursionKind distinguishes roadside rest vs walking adventure sessions.
// ExcursionKind distinguishes roadside rest vs walking adventure vs in-town tour.
type ExcursionKind string
const (
ExcursionKindNone ExcursionKind = ""
ExcursionKindRoadside ExcursionKind = "roadside"
ExcursionKindNone ExcursionKind = ""
ExcursionKindRoadside ExcursionKind = "roadside"
ExcursionKindAdventure ExcursionKind = "adventure"
ExcursionKindTown ExcursionKind = "town"
)
// ExcursionSession holds the live state of an active mini-adventure (off-road excursion).
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
// TownTourPhase is the sub-state machine while ExcursionKind == town (StateInTown).
type TownTourPhase string
const (
TownTourPhaseWander TownTourPhase = "wander"
TownTourPhaseNpcApproach TownTourPhase = "npc_approach"
TownTourPhaseNpcWelcome TownTourPhase = "npc_welcome"
TownTourPhaseNpcService TownTourPhase = "npc_service"
TownTourPhaseRest TownTourPhase = "rest"
)
// ExcursionSession holds the live state of an active mini-adventure (off-road excursion) or town tour.
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued (except Kind for town cleared on leave).
type ExcursionSession struct {
Kind ExcursionKind
Phase ExcursionPhase
@ -43,41 +56,79 @@ type ExcursionSession struct {
RoadFreezeFraction float64
// Attractor-based movement (Kind != ""): hero walks in world space toward AttractorX/Y.
StartX, StartY float64
StartX, StartY float64
AttractorX, AttractorY float64
AttractorSet bool
AttractorSet bool
// Adventure-only: wall-time when wandering should end (then return to road).
AdventureEndsAt time.Time
// Adventure: next time to pick a new wander attractor (wild phase).
// Adventure / town wander: next time to pick a new wander attractor (wild phase).
WanderNextAt time.Time
// PendingReturnAfterCombat: adventure timer elapsed; wait for combat end then enter return phase.
PendingReturnAfterCombat bool
// --- Town tour (Kind == ExcursionKindTown) ---
TownTourPhase string
// TownTourEndsAt: wall-time when the hero should leave the town (may defer until idle).
TownTourEndsAt time.Time
TownTourNpcID int64
// Stand point near NPC during approach / welcome / service.
TownTourStandX float64
TownTourStandY float64
// TownWelcomeUntil: npc_welcome phase deadline (30s, shifted while dialog open).
TownWelcomeUntil time.Time
// TownServiceUntil: npc_service phase max wall time (4 min, shifted while UI open).
TownServiceUntil time.Time
// TownRestUntil: in-town rest phase end.
TownRestUntil time.Time
TownExitPending bool
// Client has NPCDialog (welcome or service) open — shifts welcome/service deadlines.
TownTourDialogOpen bool
// Client has NPCInteraction panel open — shifts service deadline; with dialog shifts welcome too.
TownTourInteractionOpen bool
}
// Active reports whether an excursion session is in progress.
func (s *ExcursionSession) Active() bool {
if s == nil {
return false
}
if s.Kind == ExcursionKindTown {
return s.TownTourPhase != ""
}
return s.Phase != ExcursionNone
}
// ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the
// heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure.
type ExcursionPersisted struct {
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"`
WildUntil *time.Time `json:"wildUntil,omitempty"`
ReturnUntil *time.Time `json:"returnUntil,omitempty"`
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
StartX float64 `json:"startX,omitempty"`
StartY float64 `json:"startY,omitempty"`
AttractorX float64 `json:"attractorX,omitempty"`
AttractorY float64 `json:"attractorY,omitempty"`
AttractorSet bool `json:"attractorSet,omitempty"`
AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"`
WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
PendingReturnAfterCombat bool `json:"pendingReturnAfterCombat,omitempty"`
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"`
WildUntil *time.Time `json:"wildUntil,omitempty"`
ReturnUntil *time.Time `json:"returnUntil,omitempty"`
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
StartX float64 `json:"startX,omitempty"`
StartY float64 `json:"startY,omitempty"`
AttractorX float64 `json:"attractorX,omitempty"`
AttractorY float64 `json:"attractorY,omitempty"`
AttractorSet bool `json:"attractorSet,omitempty"`
AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"`
WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
PendingReturnAfterCombat bool `json:"pendingReturnAfterCombat,omitempty"`
TownTourPhase string `json:"townTourPhase,omitempty"`
TownTourEndsAt *time.Time `json:"townTourEndsAt,omitempty"`
TownTourNpcID int64 `json:"townTourNpcId,omitempty"`
TownTourStandX float64 `json:"townTourStandX,omitempty"`
TownTourStandY float64 `json:"townTourStandY,omitempty"`
TownWelcomeUntil *time.Time `json:"townWelcomeUntil,omitempty"`
TownServiceUntil *time.Time `json:"townServiceUntil,omitempty"`
TownRestUntil *time.Time `json:"townRestUntil,omitempty"`
TownExitPending bool `json:"townExitPending,omitempty"`
TownTourDialogOpen bool `json:"townTourDialogOpen,omitempty"`
TownTourInteractionOpen bool `json:"townTourInteractionOpen,omitempty"`
}

@ -67,8 +67,13 @@ type Hero struct {
RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`
// ExcursionKind is "roadside" | "adventure" during attractor-based excursions; empty otherwise.
// ExcursionKind is "roadside" | "adventure" | "town" during attractor-based sessions; empty otherwise.
ExcursionKind ExcursionKind `json:"excursionKind,omitempty"`
// TownTourPhase is set during in-town tour (wander, npc_welcome, npc_service, …); empty otherwise.
TownTourPhase string `json:"townTourPhase,omitempty"`
TownTourNpcID int64 `json:"townTourNpcId,omitempty"`
// TownTourExitPending: server wants to leave town after timers but waits for safe phase.
TownTourExitPending bool `json:"townTourExitPending,omitempty"`
// TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"`

@ -174,6 +174,25 @@ type TownNPCVisitPayload struct {
WorldY float64 `json:"worldY"`
}
// TownTourPhasePayload is sent when the in-town tour phase or NPC context changes (ExcursionKindTown).
type TownTourPhasePayload struct {
Phase string `json:"phase"`
TownID int64 `json:"townId"`
TownNameKey string `json:"townNameKey,omitempty"`
NpcID int64 `json:"npcId,omitempty"`
NpcName string `json:"npcName,omitempty"`
NpcNameKey string `json:"npcNameKey,omitempty"`
NpcType string `json:"npcType,omitempty"`
WorldX float64 `json:"worldX,omitempty"`
WorldY float64 `json:"worldY,omitempty"`
ExitPending bool `json:"exitPending,omitempty"`
}
// TownTourServiceEndPayload is sent when the NPC service phase ends by server timeout.
type TownTourServiceEndPayload struct {
Reason string `json:"reason"` // "timeout"
}
// AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log.
type AdventureLogLinePayload = AdventureLogLine

@ -0,0 +1,72 @@
package profanity
import (
"strings"
goaway "github.com/TwiN/go-away"
)
var detector *goaway.ProfanityDetector
func init() {
combined := mergeUniqueWords(
goaway.DefaultProfanities,
russianProfanityFullWords,
latinRussianProfanityFull,
)
detector = goaway.NewProfanityDetector().WithCustomDictionary(
combined,
goaway.DefaultFalsePositives,
goaway.DefaultFalseNegatives,
)
}
func mergeUniqueWords(parts ...[]string) []string {
seen := make(map[string]struct{}, 768)
out := make([]string, 0, 768)
for _, p := range parts {
for _, w := range p {
if w == "" {
continue
}
if _, ok := seen[w]; ok {
continue
}
seen[w] = struct{}{}
out = append(out, w)
}
}
return out
}
// foldYoToE maps ё→е so dictionary entries using «е» still match nicknames typed with «ё».
func foldYoToE(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case 'ё', 'Ё':
b.WriteRune('е')
default:
b.WriteRune(r)
}
}
return b.String()
}
// HeroNameIsProfane: go-away (англ. + leet) + полные русские и латинские формы; две нормализации омоглифов.
func HeroNameIsProfane(name string) bool {
folded := foldYoToE(name)
if detector.IsProfane(folded) {
return true
}
if detector.IsProfane(cyrillicHomoglyphsToLatin(folded)) {
return true
}
if hasCyrillicLetter(folded) {
if detector.IsProfane(latinAndDigitHomoglyphsToCyrillic(folded)) {
return true
}
}
return false
}

@ -0,0 +1,54 @@
package profanity
import "testing"
func TestHeroNameIsProfane_english(t *testing.T) {
if HeroNameIsProfane("Alice") {
t.Fatal("expected clean name")
}
if !HeroNameIsProfane("fuckface") {
t.Fatal("expected English profanity")
}
}
func TestHeroNameIsProfane_russianPlain(t *testing.T) {
for _, n := range []string{"ХУЙ", "хуй", "ХуЙ", "хуйло", "пиздец", "сука"} {
if !HeroNameIsProfane(n) {
t.Fatalf("expected profane: %q", n)
}
}
}
func TestHeroNameIsProfane_khersonNotBlocked(t *testing.T) {
if HeroNameIsProfane("Херсон") {
t.Fatal("did not expect city name to be blocked")
}
}
func TestHeroNameIsProfane_mixedLatinCyrillic(t *testing.T) {
cases := []string{
"xуй", // лат. x + кирил. уй
"хyй", // кирил. х + лат. y + кирил. й
"hуй", // лат. h → х
"xyи", // лат. xy + кирил. и
"cука", // лат. c → с
}
for _, n := range cases {
if !HeroNameIsProfane(n) {
t.Fatalf("expected profane (mixed script): %q", n)
}
}
}
func TestHeroNameIsProfane_digitHomoglyphs(t *testing.T) {
// смешанный ник: кириллица + цифра вместо буквы (3 → з)
if !HeroNameIsProfane("пи3да") {
t.Fatal("expected 3→з to form пизда")
}
}
func TestHeroNameIsProfane_cyrillicHomoglyphEnglish(t *testing.T) {
if !HeroNameIsProfane("fu\u0441k") { // кириллическая «с»
t.Fatal("expected spoof latin profanity to be caught")
}
}

@ -0,0 +1,96 @@
package profanity
import (
"strings"
"unicode"
)
// cyrillicHomoglyphsToLatin: кириллица, похожая на латиницу → латиница (обход «fuсс кириллической «с»).
func cyrillicHomoglyphsToLatin(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if lat, ok := cyrillicToLatinConfusable[r]; ok {
b.WriteRune(lat)
continue
}
b.WriteRune(r)
}
return b.String()
}
// latinAndDigitHomoglyphsToCyrillic: латиница и цифры, похожие на кириллицу → кириллица.
// Для ников со смешанным вводом (есть хотя бы одна кириллическая буква).
func latinAndDigitHomoglyphsToCyrillic(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if cy, ok := latinToCyrillicConfusable[r]; ok {
b.WriteRune(cy)
continue
}
if cy, ok := digitToCyrillicConfusable[r]; ok {
b.WriteRune(cy)
continue
}
b.WriteRune(unicode.ToLower(r))
}
return b.String()
}
func hasCyrillicLetter(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Cyrillic, r) {
return true
}
}
return false
}
var cyrillicToLatinConfusable = map[rune]rune{
'А': 'a', 'а': 'a',
'В': 'b', 'в': 'b',
'Е': 'e', 'е': 'e',
'З': '3', 'з': '3',
'И': 'u', 'и': 'u',
'К': 'k', 'к': 'k',
'М': 'm', 'м': 'm',
'Н': 'h', 'н': 'h',
'О': 'o', 'о': 'o',
'Р': 'p', 'р': 'p',
'С': 'c', 'с': 'c',
'Т': 't', 'т': 't',
'У': 'y', 'у': 'y',
'Х': 'x', 'х': 'x',
'Ч': '4', 'ч': '4',
}
var latinToCyrillicConfusable = map[rune]rune{
'a': 'а', 'A': 'а',
'b': 'в', 'B': 'в',
'c': 'с', 'C': 'с',
'd': 'д', 'D': 'д',
'e': 'е', 'E': 'е',
'f': 'ф', 'F': 'ф',
'h': 'х', 'H': 'х',
'i': 'и', 'I': 'и',
'k': 'к', 'K': 'к',
'm': 'м', 'M': 'м',
'o': 'о', 'O': 'о',
'p': 'р', 'P': 'р',
's': 'с', 'S': 'с',
't': 'т', 'T': 'т',
'u': 'и', 'U': 'и',
'v': 'в', 'V': 'в',
'w': 'ш', 'W': 'ш',
'x': 'х', 'X': 'х',
'y': 'у', 'Y': 'у',
}
var digitToCyrillicConfusable = map[rune]rune{
'0': 'о',
'3': 'з',
'4': 'ч',
'6': 'б',
'8': 'в',
}

@ -0,0 +1,22 @@
package profanity
// latinRussianProfanityFull — популярные латинские написания и «транслит» русского мата (цельные слова).
var latinRussianProfanityFull = []string{
"ahuel", "ahuet", "ahui", "ahuj", "ahuy",
"blya", "blyad", "blyat", "blyadina",
"dermo", "dolboeb", "dolboyob",
"ebal", "ebalo", "eban", "ebany", "ebat", "eblan", "eblo", "ebnut",
"gandon", "govno", "govnyuk",
"hren", "hrenov", "hui", "huj", "huy", "hue", "huilo", "huinya", "huesos",
"jebat", "jopa", "mudak", "mudilo",
"nahuja", "nahui", "nahuj", "nahuy", "naher", "nafig", "nafiga",
"ohuel", "ohuet", "ohueno", "oxuenno", "oxuel",
"pedik", "pedril", "pidar", "pidor", "pidoras", "pidrila",
"pizda", "pizdec", "pizdetc", "pizduk", "pizdobol", "pohui", "pohuj", "pohuy", "poher",
"sran", "srat", "suka", "suchka", "svoloch",
"trahat", "trahnut", "traxat", "traxnut",
"ueban", "uebok", "uebishche", "vyeban", "vyebon",
"xren", "xrenya", "xyi", "xyu", "xyulo", "xyesos",
"zaeb", "zaebal", "zaebis", "zalupa",
"yebat", "yeban", "yeblo",
}

@ -0,0 +1,29 @@
package profanity
// russianProfanityFullWords — цельные слова и устойчивые формы (нижний регистр, «е» вместо «ё»).
// Без коротких корней вроде «пизд», «хер» — чтобы не резать нормальные имена («Херсон» и т.д.).
var russianProfanityFullWords = []string{
"ахуеть", "ахуенно", "ахуительно",
"блядина", "блядский", "блядство", "блядь", "блять",
"въебать", "выебать", "выебон", "выебывается",
"гандон", "говно", "говнюк", "говнюшка", "говенный",
"дерьмо", "дерьмоед", "долбоеб", "долбоящер", "доебаться",
"ебало", "ебальник", "ебанутый", "ебаный", "ебарь", "ебать", "ебаться", "еблан", "ебло", "ебнутый", "ебучий",
"жопа", "жопник", "жополиз",
"залупа", "залупень", "залупонос", "заебал", "заебись", "заебать", "засранец",
"изъебнуться",
"мразь", "мразота", "мразишка", "мудак", "мудацкий", "мудила", "мудозвон", "мудень",
"нахуй", "нафиг",
"охуеть", "охуенно", "охуительно", "охуевший",
"педик", "педрила",
"пидор", "пидарас", "пидорас", "пидрила",
"пизда", "пиздец", "пиздюк", "пиздобол", "пиздюля", "пиздун", "пиздострадалец", "пиздануть", "пиздатый",
"похуй", "подъебать", "подъебнуть",
"сволочь", "сосунок", "сраный", "срать", "ссанина", "ссыкун", "сука", "сучара", "сученыш", "сучий",
"тварь", "трахать", "трахнуть",
"уебан", "уебище", "уебок",
"херня", "хренов", "хреново",
"хуй", "хуя", "хую", "хуе", "хуи", "хуйня", "хуйло", "хуесос", "хуеглот", "хуеплет", "хуиндец",
"шлюха", "шлюшка", "шлюхин",
"скотина", "ублюдок",
}

@ -98,6 +98,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/stop-adventure", adminH.StopHeroExcursion)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest)
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown)
r.Post("/heroes/{heroId}/town-tour-approach-npc", adminH.TownTourApproachNPC)
r.Post("/heroes/{heroId}/trigger-random-encounter", adminH.TriggerRandomEncounter)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)

@ -43,6 +43,17 @@ type Values struct {
// (same duration/regen as towns without NPCs). Otherwise only a short TownNPCPauseMs wait.
TownAfterNPCRestChance float64 `json:"townAfterNpcRestChance"`
// Town tour (ExcursionKindTown): attractor retarget cadence and P(stand near NPC vs random plaza wander).
TownTourWanderRetargetMinMs int64 `json:"townTourWanderRetargetMinMs"`
TownTourWanderRetargetMaxMs int64 `json:"townTourWanderRetargetMaxMs"`
TownTourNpcAttractorChance float64 `json:"townTourNpcAttractorChance"`
TownWelcomeDurationMs int64 `json:"townWelcomeDurationMs"`
TownServiceMaxMs int64 `json:"townServiceMaxMs"`
TownRestHpThreshold float64 `json:"townRestHpThreshold"`
TownRestChance float64 `json:"townRestChance"`
TownTourRestMinMs int64 `json:"townTourRestMinMs"`
TownTourRestMaxMs int64 `json:"townTourRestMaxMs"`
WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"`
MerchantCostBase int64 `json:"merchantCostBase"`
MerchantCostPerLevel int64 `json:"merchantCostPerLevel"`
@ -282,6 +293,15 @@ func DefaultValues() Values {
TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65,
TownAfterNPCRestChance: 0.78,
TownTourWanderRetargetMinMs: 5_000,
TownTourWanderRetargetMaxMs: 14_000,
TownTourNpcAttractorChance: 0.14,
TownWelcomeDurationMs: 30_000,
TownServiceMaxMs: 240_000,
TownRestHpThreshold: 0.8,
TownRestChance: 0.7,
TownTourRestMinMs: 240_000,
TownTourRestMaxMs: 360_000,
WanderingMerchantPromptTimeoutMs: 15_000,
MerchantCostBase: 20,
MerchantCostPerLevel: 5,

@ -3,4 +3,4 @@
package version
// Version is the active server build id (shown in /hero/init and admin /info).
const Version = "0.1.1-dev"
const Version = "0.2.0-dev"

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

@ -124,6 +124,30 @@ Server always sends `WSMessage`. Client always sends `WSMessage`. The `readPump`
{"type":"activate_buff","payload":{"buffType":"rage"}}
```
### Town tour (in-town excursion, `excursionKind: "town"`)
While the hero is in a town with NPCs, the server runs an **ExcursionKindTown** session: attractor wandering inside the town radius, optional rest when HP is low, deferred exit when the town timer elapses, and NPC phases (`npc_approach` → `npc_welcome``npc_service`). The client must not infer NPC panels from proximity alone.
**Server → client**
- `town_tour_phase` — payload: `phase`, `townId`, `townNameKey`, optional `npcId`, `npcName`, `npcNameKey`, `npcType`, `worldX`, `worldY`, `exitPending`. Drives which UI to show (`npc_welcome` / `npc_service` vs wander/rest).
- `town_npc_visit` — still emitted when the hero reaches the NPC stand online (adventure log / engine hooks); primary UI should follow `town_tour_phase`.
- `town_tour_service_end` — payload: `reason` (e.g. `timeout`) when the service phase hits the server max duration.
**Client → server**
- `town_tour_npc_interaction_opened` — payload `{}` (welcome → service transition; interaction timers).
- `town_tour_npc_dialog_closed` — payload `{}` (dialog dismissed; may return to wander from welcome or service).
- `town_tour_npc_interaction_closed` — payload `{}` (interaction chip dismissed or service finished).
**REST (optional, same semantics as dialog close)**
- `POST /api/v1/hero/npc-dialog-pause` with `{ "open": true|false }` maps to `TownNPCUILock` / town tour dialog-open flag; on `open: false` the engine also applies dialog-closed progression (legacy name `SkipTownNPCNarrationAfterDialog`).
**Offline**
- No WebSocket: when the hero hits an NPC stand during offline catch-up, `applyOfflineTownTourNPCVisit` resolves deterministically (quest accept if possible, else merchant upgrade only if rolled gear improves combat rating, else healer heal if HP &lt; 50%, else potion, else merchant autosell / quest-giver checked log).
### Step-by-Step Changes
#### 1. Fix WS protocol — `internal/handler/ws.go`

@ -0,0 +1,58 @@
# Unified Engine: One Simulation Path for Online and Offline
This document describes how AutoHero runs **all** gameplay logic (movement cadence, encounters, combat, rewards) in a **single** place: the Go `Engine` (`backend/internal/game/engine.go`). WebSocket is observation and command input only; there is no separate “offline world” loop that advances combat differently while the player is away.
## Authority
| Layer | Role |
|--------|------|
| **Engine** | `processMovementTick`, `processCombatTick`, `startCombatLocked`, `HeroMovement` FSM, persistence hooks |
| **WebSocket Hub** | Delivers envelopes only when the hero has at least one connected client (`SendToHero` no-op otherwise) |
| **PostgreSQL** | Durable hero row; periodic and event-driven saves from the engine |
| **Offline digest** | Aggregated summary for “while you were away” UI; filled only after disconnect grace (see below) |
## Resident heroes
- After the **last** WebSocket disconnect for a hero, `HeroSocketDetached` **does not** remove them from `e.movements` or clear combat. The hero keeps ticking like an online session without a viewer.
- In-memory `Hero.WsDisconnectedAt` is set on disconnect (aligned with `heroes.ws_disconnected_at` in the DB) for digest timing.
- **Cold start:** `ListHeroesForEngineBootstrap` (`backend/internal/storage/hero_store.go`) selects heroes with `ws_disconnected_at IS NOT NULL` and a simulatable `state`. `BootstrapResidentHeroes` (`backend/internal/game/engine_bootstrap.go`) runs a **one-shot** wall-time catch-up via `OfflineSimulator.SimulateHeroAt`, then registers the hero in the engine. Live play after that uses only engine combat.
- **Periodic save without WS:** heroes with no subscriber get a full `heroStore.Save` every `offlineDisconnectedFullSaveInterval` (30s) from the movement tick path (`backend/internal/game/engine.go`).
## Combat and encounters
- **Live progression:** encounters call `startCombatLocked`; resolution uses `e.combats` and `processCombatTick` (same for subscribed and unsubscribed heroes).
- **Batch-only paths** (no second “live” world): `SimulateOneFight` / `simulateHeroTick` remain for **bootstrap after restart** and for **server-downtime gap** recovery when the hero is **not** resident in the engine (`catchUpOfflineGap` in `backend/internal/handler/game.go`). If `HeroHasActiveMovement`, gap catch-up **skips** `SimulateHeroAt` so combat is not simulated twice.
## REST and engine consistency
- `Engine.MergeResidentHeroState` copies the authoritative in-engine hero (after `SyncToHero`) into the handlers hero struct.
- **`GET /api/v1/hero/init`** and **`GET /api/v1/hero`**: if the hero is resident, merge from engine and persist so the client and DB match the single simulation.
## Offline digest
- Helpers: `OfflineDigestGrace`, `OfflineDigestCollecting` (`backend/internal/game/offline.go`).
- The engine applies digest deltas on kill, death (including DoT death path), and auto-revive **only when** `OfflineDigestCollecting(hero.WsDisconnectedAt, now)` is true.
- Batch `simulateHeroTick` uses the same rule when a digest store is wired.
## Key source files
| Area | File |
|------|------|
| Engine loop, combat, movement, digest hooks, auto-revive, disconnected save | `backend/internal/game/engine.go` |
| Bootstrap query | `backend/internal/storage/hero_store.go` (`ListHeroesForEngineBootstrap`) |
| Bootstrap orchestration | `backend/internal/game/engine_bootstrap.go` |
| Batch catch-up + digest helpers | `backend/internal/game/offline.go` |
| Hub send if connected | `backend/internal/handler/ws.go` |
| Init / GetHero merge; gap catch-up guard | `backend/internal/handler/game.go` |
| Wiring, bootstrap before `Engine.Run` | `backend/cmd/server/main.go` |
## Scaling notes
- Bootstrap is capped (e.g. 500 heroes in `main`); not every account is loaded into RAM.
- Long-term, explicit unload policy (TTL + final save) can reduce residency memory without reintroducing a second gameplay simulator.
## Related docs
- [spec-server-authoritative.md](./spec-server-authoritative.md) — WS contract and phases.
- [excursion_attractor_fsm.md](./excursion_attractor_fsm.md) — roadside/adventure attractor excursion FSM and persistence.
- [blueprint_server_authoritative.md](./blueprint_server_authoritative.md) — historical gap analysis and migration context.

@ -0,0 +1,86 @@
# Excursion FSM: attractor movement (roadside + adventure)
This document describes the **server-authoritative** mini-excursion flow: the hero moves in **world coordinates** toward successive **attractors** instead of using a time-based perpendicular offset from the road spine.
## Terminology
| Name | `ExcursionPhase` | Meaning |
|------|------------------|---------|
| First exit into forest | `out` | Walk from the frozen road point to the first forest attractor. |
| Wilderness | `wild` | Heal / wander / encounters (depends on `ExcursionKind`). |
| Return leg | `return` | Walk back to the road (adventure) or to saved `StartX/Y` (roadside). |
Product language sometimes calls the return leg “out”; in code it is always **`return`**.
## Session kinds (`ExcursionKind`)
| Kind | Trigger | `HeroMovement` state |
|------|---------|----------------------|
| `roadside` | Low HP on road → `beginRoadsideRest` | `StateResting`, `RestKindRoadside` |
| `adventure` | Random roll while walking → `beginExcursion` | `StateWalking` with active `Excursion` |
| `town` | In-town tour (separate sub-FSM) | `StateInTown` |
Roadside and adventure share attractor stepping helpers; town uses its own tour phases (`TownTourPhase` in `model/excursion.go`).
## Kinematics
- **`CurrentX` / `CurrentY`** are the true world position during `out` / `wild` / `return`.
- Movement uses `stepTowardAttractor` (excursion speed from `refreshSpeed`) or `stepTowardWorldPoint` for town NPC/center walks, with arrival epsilon `ExcursionArrivalEpsilonWorld` (`tuning`).
- For attractor-based excursions, `displayOffset` is zero; `hero.position` / `hero_move` match world coords.
- **Legacy** JSON blobs without `excursion.kind` but with a non-empty phase are cleared on load (`applyExcursionFromBlob`) so old offset-only sessions are not resumed.
## Roadside (`roadside`)
1. **Start:** `StartX/Y` = road position; road progress frozen (`RoadFreezeWaypoint` / `RoadFreezeFraction`); first forest attractor from `pickExcursionForestAttractor` (depth from tuning).
2. **`out`:** Step toward attractor until within epsilon → **`wild`**.
3. **`wild`:** Regen `RoadsideRestHpPerS`; cap by random duration `[RoadsideRestMinMs, RoadsideRestMaxMs]` or early exit when `HP/MaxHP ≥ RoadsideRestExitHp` (default **0.85**).
4. **`return`:** Attractor = `StartX/Y`; on arrival → `endExcursion`, restore road progress, clear rest.
Persisted under `heroes.town_pause``excursion` (`ExcursionPersisted`).
## Adventure (`adventure`)
1. **Start:** `StartX/Y` on road; `AdventureEndsAt = now + uniform[AdventureDurationMinMs, AdventureDurationMaxMs]`; first `out` attractor like roadside (depth `AdventureDepthWorldUnits`).
2. **`out`:** Reach attractor → **`wild`**, schedule wander (`WanderNextAt`, `adventurePickWanderAttractor` within `AdventureWanderRadius`).
3. **`wild`:** While `now < AdventureEndsAt`: step toward current attractor; retarget on `WanderNextAt`; roll encounters; if `HP/MaxHP < LowHpThreshold``beginAdventureInlineRest` until `≥ AdventureRestTargetHp` (default **0.85**), then back to `wild`.
4. **Timer elapsed:** `tryBeginAdventureReturn`: if fighting, set `PendingReturnAfterCombat`; else `enterAdventureReturnToRoad` (attractor = closest point on **frozen** road polyline).
5. **After combat win:** `ResumeWalking` then `TryAdventureReturnAfterCombat(now)` — also handles timer elapsed while movement ticks were skipped during combat (checks `AdventureEndsAt`, not only the pending flag).
6. **`return`:** On arrival at road attractor → `endExcursion`, `excursion_end` WS when applicable.
## Persistence
- `TownPausePersisted.Excursion` holds kind, phase, freeze snapshot, `startX/Y`, attractor, `adventureEndsAt`, `wanderNextAt`, `pendingReturnAfterCombat`, etc.
- Engine persists when `TownPausePersistDue` signature changes (see `townPausePersistSignature` in `movement.go`).
## WebSocket (online)
Typical messages: `excursion_start`, `excursion_phase`, `hero_move`, `hero_state`, `excursion_end`. After admin “force return” on adventure, engine sends `excursion_phase` + `hero_move` (not immediate `excursion_end`).
## Tuning keys (reference)
| Key | Role |
|-----|------|
| `AdventureDurationMinMs` / `MaxMs` | Adventure `wild` window |
| `AdventureWanderRadius` | Random retarget radius around hero |
| `AdventureWanderRetargetMinMs` / `MaxMs` | Retarget interval |
| `ExcursionArrivalEpsilonWorld` | Arrival threshold (shared with town step-to-point) |
| `RoadsideRestExitHp` | Early end of roadside `wild` |
| `AdventureRestTargetHp` | End of adventure inline heal |
## Client
- `excursionPhase` and `excursionKind` (`roadside` \| `adventure`) on hero JSON; visuals follow `hero_move` world coordinates.
## Primary source files
| Area | File |
|------|------|
| Session model + persist DTO | `backend/internal/model/excursion.go` |
| FSM, stepping, persist blob | `backend/internal/game/movement.go` |
| Post-combat return | `backend/internal/game/engine.go`, `offline.go` |
| Defaults | `backend/internal/tuning/runtime.go` |
## Related docs
- [engine_unified_offline_online.md](./engine_unified_offline_online.md) — single simulation path.
- [spec-server-authoritative.md](./spec-server-authoritative.md) — WS envelopes and authority boundaries.

@ -6,6 +6,8 @@ Date: 2026-03-27
This spec is the contract between the backend and frontend agents.
Each phase is independently deployable. Phases must ship in order.
**Unified simulation (online/offline):** gameplay ticks run only in the Go `Engine`; WS is observe/commands. See [engine_unified_offline_online.md](./engine_unified_offline_online.md).
---
## 1. WebSocket Message Protocol

@ -10,6 +10,9 @@ import {
sendRevive,
sendNPCAlmsAccept,
sendNPCAlmsDecline,
sendTownTourNPCDialogClosed,
sendTownTourNPCInteractionOpened,
sendTownTourNPCInteractionClosed,
buildLootFromCombatEnd,
buildMerchantLootDrop,
} from './game/ws-handler';
@ -32,7 +35,16 @@ import {
offlineReportHasActivity,
} from './network/api';
import type { HeroResponse, Achievement, ChangelogPayload } from './network/api';
import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types';
import type {
AdventureLogEntry,
Town,
HeroQuest,
NPC,
TownData,
EquipmentItem,
BuildingData,
TownTourPhasePayload,
} from './game/types';
import type { OfflineReport as OfflineReportData } from './network/api';
import {
BUFF_COOLDOWN_MS,
@ -223,6 +235,22 @@ function mapEquipment(
return out;
}
function townTourPayloadToNPCData(p: TownTourPhasePayload, loc: Locale, town: Town | null): NPCData {
const tw = town ?? undefined;
const displayName = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName ?? '') : (p.npcName ?? '');
return {
id: p.npcId ?? 0,
name: displayName,
nameKey: p.npcNameKey,
type: (p.npcType ?? 'merchant') as NPCData['type'],
worldX: p.worldX ?? 0,
worldY: p.worldY ?? 0,
townId: p.townId,
townLevelMin: tw?.levelMin ?? 1,
townLevelMax: tw?.levelMax ?? 1,
};
}
/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */
function townToTownData(
town: Town,
@ -268,9 +296,14 @@ function heroResponseToState(res: HeroResponse): HeroState {
restKind: res.restKind,
excursionPhase: res.excursionPhase,
excursionKind:
res.excursionKind === 'roadside' || res.excursionKind === 'adventure'
? res.excursionKind
res.excursionKind === 'roadside' ||
res.excursionKind === 'adventure' ||
res.excursionKind === 'town'
? (res.excursionKind as HeroState['excursionKind'])
: undefined,
townTourPhase: res.townTourPhase,
townTourNpcId: res.townTourNpcId,
townTourExitPending: res.townTourExitPending,
attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense,
@ -379,11 +412,26 @@ export function App() {
const [heroSheetOpen, setHeroSheetOpen] = useState(false);
const [heroSheetInitialTab, setHeroSheetInitialTab] = useState<HeroSheetTab>('stats');
// NPC interaction state (server-driven via town_enter)
// NPC interaction (legacy / non-tour); town tour uses `townTourLastPayload` (NPCInteraction first; NPCDialog only after tap).
const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null);
/** Server signaled a town NPC visit; UI waits until the hero display reaches the NPC. */
const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState<NPCData | null>(null);
const [townTourLastPayload, setTownTourLastPayload] = useState<TownTourPhasePayload | null>(null);
const townTourDialogWs = useMemo(
() => ({
onInteractionOpened: () => {
const w = wsRef.current;
if (w) sendTownTourNPCInteractionOpened(w);
},
onDialogAndInteractionClosed: () => {
const w = wsRef.current;
if (!w) return;
sendTownTourNPCDialogClosed(w);
sendTownTourNPCInteractionClosed(w);
},
}),
[],
);
// Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
@ -894,38 +942,39 @@ export function App() {
setToast({ message: t(bundle.entering, { townName: townDisp }), color: '#daa520' });
appendLogClientMessage(formatClientLogLine(bundle, 'enteredTown', { town: townDisp }));
setNearestNPC(null);
setNpcVisitAwaitingProximity(null);
setSelectedNPC(null);
setNpcInteractionDismissed(null);
setTownTourLastPayload(null);
},
onAdventureLogLine: (p) => {
appendLogPayload(p);
},
onTownNPCVisit: (p) => {
const loc = i18nForLogRef.current.locale;
setNearestNPC(null);
setNpcInteractionDismissed(null);
const displayName = p.nameKey ? npcLabel(loc, p.nameKey, p.name) : p.name;
const tw = townsRef.current.find((t) => t.id === p.townId);
setNpcVisitAwaitingProximity({
id: p.npcId,
name: displayName,
nameKey: p.nameKey,
type: p.type as NPCData['type'],
worldX: p.worldX ?? 0,
worldY: p.worldY ?? 0,
townId: p.townId,
townLevelMin: tw?.levelMin ?? 1,
townLevelMax: tw?.levelMax ?? 1,
});
onTownNPCVisit: () => {
// Town NPC UI is driven by `town_tour_phase` (engine still gets lines via ws-handler).
},
onTownTourPhase: (p) => {
setTownTourLastPayload(p);
const ph = p.phase;
if (ph === 'npc_welcome' || ph === 'npc_service') {
setNpcInteractionDismissed(null);
}
if (ph === 'wander' || ph === 'rest' || ph === 'npc_approach') {
setSelectedNPC(null);
}
},
onTownTourServiceEnd: () => {
setTownTourLastPayload(null);
setSelectedNPC(null);
},
onTownExit: () => {
setCurrentTown(null);
setNearestNPC(null);
setNpcVisitAwaitingProximity(null);
setTownTourLastPayload(null);
},
onNPCEncounter: (p) => {
@ -1032,43 +1081,6 @@ export function App() {
};
}, []);
// Open trader / quest / healer panel only after the hero sprite has reached the NPC (not on town_enter).
useEffect(() => {
if (!npcVisitAwaitingProximity) return;
const pending = npcVisitAwaitingProximity;
const proximityR = 0.55;
const proximityR2 = proximityR * proximityR;
const timeoutMs = 5000;
const started = performance.now();
let raf = 0;
const step = () => {
const eng = engineRef.current;
let closeEnough = false;
if (eng) {
const { x, y } = eng.getHeroDisplayWorldPosition();
const dx = x - pending.worldX;
const dy = y - pending.worldY;
closeEnough = dx * dx + dy * dy <= proximityR2;
}
if (closeEnough || performance.now() - started > timeoutMs) {
const role =
pending.type === 'merchant'
? tr.shopLabel
: pending.type === 'healer'
? tr.healerLabel
: tr.questLabel;
setToast({ message: `${role}: ${pending.name}`, color: '#c9a227' });
setNearestNPC(pending);
setNpcVisitAwaitingProximity(null);
return;
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [npcVisitAwaitingProximity, tr]);
// Restore per-hero buff button cooldowns
useEffect(() => {
const id = gameState.hero?.id;
@ -1353,11 +1365,16 @@ export function App() {
setNpcInteractionDismissed(npc.id);
}, []);
const handleNPCInteractionDismiss = useCallback(() => {
if (nearestNPC) {
setNpcInteractionDismissed(nearestNPC.id);
const handleTownTourInteractionDismiss = useCallback(() => {
const w = wsRef.current;
if (!w) return;
const ph = townTourLastPayload?.phase;
if (ph === 'npc_welcome') {
sendTownTourNPCDialogClosed(w);
} else if (ph === 'npc_service') {
sendTownTourNPCInteractionClosed(w);
}
}, [nearestNPC]);
}, [townTourLastPayload?.phase]);
// ---- Wandering NPC Encounter Handlers (via WS) ----
@ -1379,13 +1396,33 @@ export function App() {
appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'declinedWanderingMerchant'));
}, [appendLogClientMessage]);
// Show NPC interaction when near an NPC and not dismissed
const heroOnTownTour = gameState.hero?.excursionKind === 'town';
const townTourChipActive =
(townTourLastPayload?.phase === 'npc_welcome' || townTourLastPayload?.phase === 'npc_service') &&
(townTourLastPayload.npcId ?? 0) > 0;
const townTourInteractionNPC =
townTourChipActive && townTourLastPayload
? townTourPayloadToNPCData(townTourLastPayload, locale, currentTown)
: null;
const legacyProximityNPC = !heroOnTownTour ? nearestNPC : null;
const interactionNpc = townTourInteractionNPC ?? legacyProximityNPC;
const showNPCInteraction =
nearestNPC !== null &&
npcInteractionDismissed !== nearestNPC.id &&
interactionNpc != null &&
npcInteractionDismissed !== interactionNpc.id &&
(gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) &&
!selectedNPC;
const dialogNpc = selectedNPC;
const handleNPCInteractionDismiss = useCallback(() => {
if (interactionNpc) setNpcInteractionDismissed(interactionNpc.id);
}, [interactionNpc]);
const completedQuestCount = useMemo(
() =>
heroQuests.filter((q) => q.status === 'completed').length,
@ -1497,20 +1534,21 @@ export function App() {
</div>
)}
{/* NPC Proximity Interaction */}
{showNPCInteraction && nearestNPC && (
{/* Town tour service chip or legacy proximity NPC */}
{showNPCInteraction && interactionNpc && (
<NPCInteraction
npc={nearestNPC}
npc={interactionNpc}
onViewQuests={handleNPCViewQuests}
onOpenServiceDialog={handleNPCViewQuests}
onDismiss={handleNPCInteractionDismiss}
onDismissTownTour={townTourInteractionNPC ? handleTownTourInteractionDismiss : undefined}
/>
)}
{/* NPC Dialog */}
{selectedNPC && (
{/* NPC Dialog: opened from interaction / sheet only (not auto on town tour approach). */}
{dialogNpc && (
<NPCDialog
npc={selectedNPC}
npc={dialogNpc}
heroQuests={heroQuests}
heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
@ -1521,6 +1559,7 @@ export function App() {
onHeroUpdated={handleNPCHeroUpdated}
onToast={(message, color) => setToast({ message, color })}
questClaimDisabled={questClaimDisabled}
townTourWs={heroOnTownTour && selectedNPC ? townTourDialogWs : undefined}
/>
)}

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

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

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

@ -34,6 +34,14 @@ interface NPCDialogProps {
onToast: (message: string, color: string) => void;
/** Block quest reward claim while hero is dead. */
questClaimDisabled?: boolean;
/**
* When set, notifies the server for ExcursionKindTown: interaction opened on mount,
* dialog + interaction closed on unmount (WebSocket commands).
*/
townTourWs?: {
onInteractionOpened: () => void;
onDialogAndInteractionClosed: () => void;
};
}
// ---- Styles ----
@ -290,6 +298,7 @@ export function NPCDialog({
onHeroUpdated,
onToast,
questClaimDisabled = false,
townTourWs,
}: NPCDialogProps) {
const tr = useT();
const { locale } = useLocale();
@ -302,19 +311,21 @@ export function NPCDialog({
const telegramId = getTelegramUserId() ?? 1;
// Pause town NPC visit timers while any NPC dialog (shop / quests / healer) is open.
// Pause town tour timers while any NPC dialog (shop / quests / healer) is open.
useEffect(() => {
let cancelled = false;
setNPCDialogPause(true, telegramId).catch((err) => {
if (!cancelled) console.warn('[NPCDialog] npc-dialog-pause (open) failed:', err);
});
townTourWs?.onInteractionOpened();
return () => {
cancelled = true;
setNPCDialogPause(false, telegramId, { advanceTownVisit: true }).catch((err) => {
setNPCDialogPause(false, telegramId).catch((err) => {
console.warn('[NPCDialog] npc-dialog-pause (close) failed:', err);
});
townTourWs?.onDialogAndInteractionClosed();
};
}, [telegramId]);
}, [telegramId, townTourWs]);
// Merchant: roll stock when the shop opens (server-tier gear for this town).
useEffect(() => {

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

@ -125,7 +125,12 @@ export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) {
} else if (err.status === 400) {
try {
const j = JSON.parse(err.body) as { error?: string };
setError(j.error ?? tr.invalidName);
const errMsg = j.error ?? '';
if (errMsg === 'invalid name: inappropriate language') {
setError(tr.invalidName);
} else {
setError(errMsg || tr.invalidName);
}
} catch {
setError(tr.invalidName);
}

Loading…
Cancel
Save