From 3d0b050cce3d7b767e1b04cc037325965aec57a4 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 18:15:37 +0300 Subject: [PATCH] town tour --- .../rules/server-authoritative-blueprint.mdc | 6 + admin-web/index.html | 82 ++++ backend/internal/game/engine.go | 78 ++- backend/internal/game/movement.go | 446 ++++-------------- backend/internal/game/offline.go | 221 +++++---- backend/internal/game/town_merchant_gear.go | 14 + backend/internal/handler/admin.go | 116 ++++- backend/internal/handler/npc.go | 13 +- backend/internal/model/excursion.go | 101 +++- backend/internal/model/hero.go | 7 +- backend/internal/model/ws_message.go | 19 + backend/internal/router/router.go | 1 + backend/internal/tuning/runtime.go | 20 + .../000027_gear_encounter_balance.sql | 2 +- docs/blueprint_server_authoritative.md | 24 + docs/spec-server-authoritative.md | 2 + frontend/src/App.tsx | 189 +++++--- frontend/src/game/types.ts | 28 +- frontend/src/game/ws-handler.ts | 26 + frontend/src/network/api.ts | 17 +- frontend/src/ui/NPCDialog.tsx | 17 +- frontend/src/ui/NPCInteraction.tsx | 8 +- 22 files changed, 816 insertions(+), 621 deletions(-) diff --git a/.cursor/rules/server-authoritative-blueprint.mdc b/.cursor/rules/server-authoritative-blueprint.mdc index 3e0dc4c..cdef12f 100644 --- a/.cursor/rules/server-authoritative-blueprint.mdc +++ b/.cursor/rules/server-authoritative-blueprint.mdc @@ -31,6 +31,9 @@ All messages (both directions) use `{"type": string, "payload": object}`. Text ` | `level_up` | `newLevel`, all stat fields | | `buff_applied` | `buffType`, `magnitude`, `durationMs`, `expiresAt` | | `debuff_applied` | `debuffType`, `magnitude`, `durationMs`, `expiresAt` | +| `town_tour_phase` | `phase`, `townId`, optional NPC fields, `exitPending` | +| `town_tour_service_end` | `reason` (e.g. timeout) | +| `town_npc_visit` | NPC stand snapshot (log/engine; UI follows `town_tour_phase`) | ### Client → Server Commands @@ -39,6 +42,9 @@ All messages (both directions) use `{"type": string, "payload": object}`. Text ` | `request_encounter` | `{}` | | `request_revive` | `{}` | | `activate_buff` | `{"buffType": "rage"}` | +| `town_tour_npc_interaction_opened` | `{}` | +| `town_tour_npc_dialog_closed` | `{}` | +| `town_tour_npc_interaction_closed` | `{}` | ## Critical Gaps (P0 — must fix) diff --git a/admin-web/index.html b/admin-web/index.html index 72af481..c85cae7 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -197,6 +197,9 @@ gearFilterSubtype: "", grantGearSearchQuery: "", heroGrantGearCandidates: [], + /** NPC list for «Подойти к NPC» (town tour admin); from GET quests/towns/{id}/npcs */ + townTourApproachNpcs: [], + townTourApproachNpcTownId: null, heroGrantFilterSlot: "", heroGrantFilterRarity: "", heroGrantFilterSubtype: "", @@ -1139,13 +1142,35 @@ if (h.currentTownId != null) rows.push(`
currentTownId
${e(h.currentTownId)}
`); if (h.destinationTownId != null) rows.push(`
destinationTownId
${e(h.destinationTownId)}
`); if (h.restKind) rows.push(`
restKind
${e(h.restKind)}
`); + if (h.excursionKind) { + rows.push(`
excursionKind (hero)
${e(h.excursionKind)}
`); + } if (live && live.online) { + if (live.excursionKind) rows.push(`
excursionKind (live)
${e(live.excursionKind)}
`); + if (live.excursionPhase) rows.push(`
excursionPhase
${e(live.excursionPhase)}
`); if (live.restUntil) rows.push(`
отдых / restUntil
${statusCountdownLine(live.restUntil)}
`); if (live.townLeaveAt) rows.push(`
в городе до выхода
${statusCountdownLine(live.townLeaveAt)}
`); if (live.nextTownNPCRollAt) rows.push(`
след. событие NPC в городе
${statusCountdownLine(live.nextTownNPCRollAt)}
`); if (live.wanderingMerchantDeadline) { rows.push(`
окно бродячего торговца
${statusCountdownLine(live.wanderingMerchantDeadline)}
`); } + const tt = live.townTour; + if (tt && typeof tt === "object") { + rows.push(`
Экскурсия по городу (town tour)
`); + if (tt.phase) rows.push(`
phase
${e(tt.phase)}
`); + if (tt.npcId) rows.push(`
npcId
${e(tt.npcId)}
`); + if (tt.townTourEndsAt) rows.push(`
конец пребывания в городе
${statusCountdownLine(tt.townTourEndsAt)}
`); + if (tt.wanderNextAt) rows.push(`
след. смена аттрактора
${statusCountdownLine(tt.wanderNextAt)}
`); + if (tt.townWelcomeUntil) rows.push(`
welcome до
${statusCountdownLine(tt.townWelcomeUntil)}
`); + if (tt.townServiceUntil) rows.push(`
service до
${statusCountdownLine(tt.townServiceUntil)}
`); + if (tt.townRestUntil) rows.push(`
отдых в туре
${statusCountdownLine(tt.townRestUntil)}
`); + if (tt.townExitPending) rows.push(`
выход из города
ожидает безопасной фазы
`); + if (tt.townTourDialogOpen) rows.push(`
UI
NPCDialog открыт (таймеры сдвигаются)
`); + if (tt.townTourInteractionOpen) rows.push(`
UI
interaction открыт
`); + if (tt.townTourStandX != null && tt.townTourStandY != null) { + rows.push(`
stand
${e(tt.townTourStandX)}, ${e(tt.townTourStandY)}
`); + } + } } if (tp) { if (tp.restUntil) rows.push(`
отдых (из БД)
${e(tp.restKind || "")}: ${statusCountdownLine(tp.restUntil)}
`); @@ -1202,8 +1227,47 @@ state.selectedHeroId = heroId; const [hero, gear, quests] = await Promise.all([api(`heroes/${heroId}`), api(`heroes/${heroId}/gear`), api(`heroes/${heroId}/quests`)]); state.selectedHero = hero; state.gear = gear; state.quests = quests; + const newTid = hero.currentTownId; + if (Number(state.townTourApproachNpcTownId) !== Number(newTid)) { + state.townTourApproachNpcs = []; + state.townTourApproachNpcTownId = null; + } render(); } + async function loadTownTourApproachNpcs() { + const h = state.selectedHero; + if (!h || !state.selectedHeroId) { + setMessage("Сначала выберите героя"); + return; + } + const townId = h.currentTownId; + if (!townId) { + setMessage("У героя нет currentTownId (не в городе?)"); + return; + } + const data = await api(`quests/towns/${townId}/npcs`); + state.townTourApproachNpcs = data.npcs || []; + state.townTourApproachNpcTownId = townId; + setMessage(`NPC города #${townId}: ${state.townTourApproachNpcs.length}`); + render(); + } + async function townTourApproachSelectedNpc() { + if (!state.selectedHeroId) return; + const sel = document.getElementById("town-tour-approach-npc-select"); + const raw = sel && sel.value ? String(sel.value).trim() : ""; + const npcId = raw ? Number(raw) : 0; + if (!npcId) { + setMessage("Выберите NPC в списке"); + return; + } + await api(`heroes/${state.selectedHeroId}/town-tour-approach-npc`, { + method: "POST", + body: JSON.stringify({ npcId }), + }); + await loadHero(state.selectedHeroId); + startHeroMovementPoll(60); + setMessage(`Подход к NPC #${npcId} (town tour)`); + } async function heroAction(action, body = {}, pollMovement = false) { if (!state.selectedHeroId) return; await api(`heroes/${state.selectedHeroId}/${action}`, { method: "POST", body: JSON.stringify(body) }); @@ -2031,6 +2095,7 @@ let heroExtra = ""; let teleportOpts = ``; + let townTourApproachPanel = ""; if (state.selectedHeroId) { const rowsDbH = state.contentGearRows || []; const rowsCatH = state.gearCatalog || []; @@ -2049,6 +2114,22 @@ const hgSubOpts = `` + hgSubs.map(s => ``).join(""); const hgRarOpts = `` + hgRars.map(s => ``).join(""); teleportOpts = `` + (state.teleportTowns || []).map(t => ``).join(""); + const liveMov = h.adminLiveMovement; + const showTownTourAdmin = h.excursionKind === "town" && liveMov && liveMov.online; + const npcListApproach = state.townTourApproachNpcs || []; + const npcOptsApproach = npcListApproach.map(n => + `` + ).join(""); + townTourApproachPanel = showTownTourAdmin ? ` +
+

Экскурсия по городу

+

Только для онлайн-героя с excursionKind=town. Текущий город: ${e(h.currentTownId)}. Детали — блок «Путь, город, отдых».

+ +
+ + +
+
` : ""; const equipped = state.gear?.equipped || {}; const inventory = state.gear?.inventory || []; const slotRows = Object.keys(equipped).sort().map(slot => ` @@ -2214,6 +2295,7 @@

Города из графа (GET /admin/towns). Герой жив и не в бою.

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