Введите имя или id и нажмите «Найти», либо «50 последних»
`;
+
let heroExtra = "";
let teleportOpts = ``;
let townTourApproachPanel = "";
@@ -2287,6 +2356,23 @@
+
+
Встреча героев
+
Основной — открытый герой. Выберите второго в списке (поиск как на вкладке героев). Нельзя выбрать того же героя. POST …/start-hero-meet — партнёр в движке, телепорт к основному.
+
+
+
+
+
+
+
+
+
+
+
Партнёр: ${hmPick.otherId ? `ID ${e(hmPick.otherId)} ${e(hmPick.pickName || "")}` : "— клик по строке (не текущий герой)"}
+
${hmRowsHtml}
+
+
Roadside / adventure: герой жив, не в бою; adventure — StateWalking на дороге.
@@ -2748,6 +2834,10 @@
window.searchHeroesForCombatSim = searchHeroesForCombatSim;
window.loadRecentHeroesForCombatSim = loadRecentHeroesForCombatSim;
window.selectCombatSimHero = selectCombatSimHero;
+ window.searchHeroesForHeroMeetPartner = searchHeroesForHeroMeetPartner;
+ window.loadRecentHeroesForHeroMeetPartner = loadRecentHeroesForHeroMeetPartner;
+ window.selectHeroMeetPartner = selectHeroMeetPartner;
+ window.startHeroMeetAdmin = startHeroMeetAdmin;
window.applyCombatSimEnemyFilter = applyCombatSimEnemyFilter;
window.onCombatSimEnemyTypeChange = onCombatSimEnemyTypeChange;
render();
diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go
index b752cfc..31bfbe1 100644
--- a/backend/internal/game/combat.go
+++ b/backend/internal/game/combat.go
@@ -428,6 +428,7 @@ func CheckDeath(hero *model.Hero, now time.Time) bool {
}
hero.State = model.StateDead
+
return true
}
diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go
index b701ac0..3f388a6 100644
--- a/backend/internal/game/engine.go
+++ b/backend/internal/game/engine.go
@@ -94,6 +94,9 @@ type Engine struct {
// merchantStock: ephemeral town merchant rows (heroID) until purchase or dialog close.
merchantStock map[int64]*merchantOfferSession
+
+ heroMeetLastRoll map[int64]time.Time
+ heroMeetLastMsg map[int64]time.Time
}
// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected.
@@ -114,6 +117,8 @@ func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *s
logger: logger,
lastDisconnectedFullSave: make(map[int64]time.Time),
merchantStock: make(map[int64]*merchantOfferSession),
+ heroMeetLastRoll: make(map[int64]time.Time),
+ heroMeetLastMsg: make(map[int64]time.Time),
}
heap.Init(&e.queue)
return e
@@ -123,6 +128,21 @@ func (e *Engine) GetMovements(heroId int64) *HeroMovement {
return e.movements[heroId]
}
+// LiveHeroByTelegramID returns the resident in-memory hero for an active movement session, or nil.
+func (e *Engine) LiveHeroByTelegramID(telegramID int64) *model.Hero {
+ if e == nil || telegramID == 0 {
+ return nil
+ }
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ for _, hm := range e.movements {
+ if hm != nil && hm.Hero != nil && hm.Hero.TelegramID == telegramID {
+ return hm.Hero
+ }
+ }
+ return nil
+}
+
// MergeResidentHeroState copies the authoritative in-engine hero into dst after SyncToHero.
// Returns false if the hero is not resident. Used by REST init so the client sees the same state the Engine simulates.
func (e *Engine) MergeResidentHeroState(dst *model.Hero) bool {
@@ -161,6 +181,27 @@ func (e *Engine) HeroWorldPositionForCombat(heroID int64) (x, y float64, ok bool
return hm.CurrentX + ox, hm.CurrentY + oy, true
}
+// OverlayResidentWorldPositionsOnNearby overwrites each summary's PositionX/Y when that hero
+// has an active movement session (authoritative in-engine coords). Used by GET /hero/nearby so
+// clients see meet-stand / frozen positions instead of stale DB rows.
+func (e *Engine) OverlayResidentWorldPositionsOnNearby(heroes []storage.HeroSummary) {
+ if len(heroes) == 0 {
+ return
+ }
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ now := time.Now()
+ for i := range heroes {
+ hm := e.movements[heroes[i].ID]
+ if hm == nil || hm.Hero == nil {
+ continue
+ }
+ ox, oy := hm.displayOffset(now)
+ heroes[i].PositionX = hm.CurrentX + ox
+ heroes[i].PositionY = hm.CurrentY + oy
+ }
+}
+
// RoadGraph returns the loaded world graph (for admin tools), or nil.
func (e *Engine) RoadGraph() *RoadGraph {
e.mu.RLock()
@@ -409,6 +450,10 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) {
e.handleTownTourNPCInteractionOpened(msg)
case "town_tour_npc_interaction_closed":
e.handleTownTourNPCInteractionClosed(msg)
+ case "hero_meet_send_message":
+ e.handleHeroMeetSendMessage(msg)
+ case "hero_meet_end_conversation":
+ e.handleHeroMeetEndConversation(msg)
default:
// Commands like accept_quest, claim_quest, npc_interact etc.
// are handled by their respective REST handlers for now.
@@ -589,7 +634,8 @@ func (e *Engine) handleNPCAlmsDecline(msg IncomingMessage) {
}
}
-// handleRevive processes the revive client command (same rules as POST /api/v1/hero/revive).
+// handleRevive processes the revive client command (same rules as POST /api/v1/hero/revive;
+// both paths use the resident in-memory hero only).
func (e *Engine) handleRevive(msg IncomingMessage) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -674,6 +720,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
Enemy: enemyToInfo(&cs.Enemy),
})
}
+ e.pushHeroMeetIfActiveLocked(hero.ID)
}
return
}
@@ -719,6 +766,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
Enemy: enemyToInfo(&cs.Enemy),
})
}
+ e.pushHeroMeetIfActiveLocked(hero.ID)
}
}
@@ -1948,6 +1996,30 @@ func (e *Engine) processMovementTick(now time.Time) {
}
}
}
+
+ e.checkHeroMeetApproachArrivalLocked(now)
+ e.checkHeroMeetReturnArrivalLocked(now)
+ e.tryRandomHeroMeetProximityLocked(now)
+ e.processHeroMeetTickLocked(now)
+ for heroID, hm := range e.movements {
+ if hm == nil || e.heroStore == nil || hm.Hero == nil {
+ continue
+ }
+ if sig, ok := hm.TownPausePersistDue(); ok {
+ hm.SyncToHero()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ err := e.heroStore.Save(ctx, hm.Hero)
+ cancel()
+ if err != nil {
+ if e.logger != nil {
+ e.logger.Error("persist hero after hero meet tick", "hero_id", heroID, "error", err)
+ }
+ continue
+ }
+ hm.MarkTownPausePersisted(sig)
+ e.syncTownSessionRedis(heroID, hm)
+ }
+ }
}
// mergeTownSessionFromRedis overlays a fresher in-town snapshot when Postgres row is stale (e.g. missed town_pause save).
diff --git a/backend/internal/game/hero_meet.go b/backend/internal/game/hero_meet.go
new file mode 100644
index 0000000..1076664
--- /dev/null
+++ b/backend/internal/game/hero_meet.go
@@ -0,0 +1,944 @@
+package game
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "math/rand"
+ "time"
+ "unicode/utf8"
+
+ "github.com/denisovdennis/autohero/internal/model"
+ "github.com/denisovdennis/autohero/internal/profanity"
+ "github.com/denisovdennis/autohero/internal/storage"
+ "github.com/denisovdennis/autohero/internal/tuning"
+)
+
+func heroMeetOrderedPair(a, b int64) (lo, hi int64) {
+ if a < b {
+ return a, b
+ }
+ return b, a
+}
+
+func (e *Engine) heroMeetAnySubscriberOnline(a, b int64) bool {
+ if e.heroSubscriber == nil {
+ return false
+ }
+ return e.heroSubscriber(a) || e.heroSubscriber(b)
+}
+
+// heroMeetPlayerOnline is true when this hero has an active WebSocket (player connected).
+func (e *Engine) heroMeetPlayerOnline(heroID int64) bool {
+ if e.heroSubscriber == nil {
+ return false
+ }
+ return e.heroSubscriber(heroID)
+}
+
+func (e *Engine) copyExcursionHeroMeetFields(dst, src *model.ExcursionSession) {
+ if dst == nil || src == nil {
+ return
+ }
+ dst.HeroMeetPartnerID = src.HeroMeetPartnerID
+ dst.HeroMeetSubPhase = src.HeroMeetSubPhase
+ dst.HeroMeetPromptUntil = src.HeroMeetPromptUntil
+ dst.HeroMeetNextAutoAt = src.HeroMeetNextAutoAt
+ dst.HeroMeetTurnHeroID = src.HeroMeetTurnHeroID
+ dst.HeroMeetHadPlayerMessage = src.HeroMeetHadPlayerMessage
+ dst.HeroMeetOfflineDeadline = src.HeroMeetOfflineDeadline
+ dst.HeroMeetOfflineTimerRunning = src.HeroMeetOfflineTimerRunning
+ dst.HeroMeetOfflineRemainingMs = src.HeroMeetOfflineRemainingMs
+ dst.HeroMeetAutoLineIdx = src.HeroMeetAutoLineIdx
+ dst.HeroMeetAnchorX = src.HeroMeetAnchorX
+ dst.HeroMeetAnchorY = src.HeroMeetAnchorY
+ dst.Phase = src.Phase
+}
+
+func (e *Engine) syncHeroMeetPartnerExcursion(leader *HeroMovement, partner *HeroMovement) {
+ if leader == nil || partner == nil {
+ return
+ }
+ lid := leader.HeroID
+ e.copyExcursionHeroMeetFields(&partner.Excursion, &leader.Excursion)
+ partner.Excursion.HeroMeetPartnerID = lid
+}
+
+func randomHeroMeetOfflineDuration(cfg tuning.Values) time.Duration {
+ minMs := cfg.HeroMeetOfflineMinMs
+ maxMs := cfg.HeroMeetOfflineMaxMs
+ if minMs <= 0 {
+ minMs = tuning.DefaultValues().HeroMeetOfflineMinMs
+ }
+ if maxMs <= 0 {
+ maxMs = tuning.DefaultValues().HeroMeetOfflineMaxMs
+ }
+ if maxMs < minMs {
+ maxMs = minMs
+ }
+ delta := maxMs - minMs
+ return time.Duration(minMs+rand.Int63n(delta+1)) * time.Millisecond
+}
+
+func heroMeetHeroNearAttractor(hm *HeroMovement) bool {
+ if hm == nil || !hm.Excursion.AttractorSet {
+ return false
+ }
+ eps := ExcursionArrivalEpsilonWorld()
+ dx := hm.Excursion.AttractorX - hm.CurrentX
+ dy := hm.Excursion.AttractorY - hm.CurrentY
+ return math.Hypot(dx, dy) <= eps
+}
+
+func (e *Engine) transitionHeroMeetDialogueTimersLocked(lo, hi int64, now time.Time) {
+ leader := e.movements[lo]
+ partner := e.movements[hi]
+ if leader == nil || partner == nil {
+ return
+ }
+ ex := &leader.Excursion
+ cfg := tuning.Get()
+ anyOnline := e.heroMeetAnySubscriberOnline(lo, hi)
+ offlineBudget := time.Duration(ex.HeroMeetOfflineRemainingMs) * time.Millisecond
+ if offlineBudget <= 0 {
+ offlineBudget = randomHeroMeetOfflineDuration(cfg)
+ ex.HeroMeetOfflineRemainingMs = offlineBudget.Milliseconds()
+ }
+ promptMs := cfg.HeroMeetPromptWindowMs
+ if promptMs <= 0 {
+ promptMs = tuning.DefaultValues().HeroMeetPromptWindowMs
+ }
+ autoInt := cfg.HeroMeetAutoLineIntervalMs
+ if autoInt <= 0 {
+ autoInt = tuning.DefaultValues().HeroMeetAutoLineIntervalMs
+ }
+ if anyOnline {
+ ex.HeroMeetSubPhase = model.HeroMeetSubPrompt
+ ex.HeroMeetPromptUntil = now.Add(time.Duration(promptMs) * time.Millisecond)
+ ex.HeroMeetOfflineTimerRunning = false
+ ex.HeroMeetNextAutoAt = ex.HeroMeetPromptUntil
+ } else {
+ ex.HeroMeetSubPhase = model.HeroMeetSubAuto
+ ex.HeroMeetOfflineTimerRunning = true
+ ex.HeroMeetOfflineDeadline = now.Add(offlineBudget)
+ ex.HeroMeetNextAutoAt = now.Add(time.Duration(autoInt) * time.Millisecond)
+ }
+ e.syncHeroMeetPartnerExcursion(leader, partner)
+}
+
+// checkHeroMeetApproachArrivalLocked promotes out → meet when both heroes reach approach attractors.
+func (e *Engine) checkHeroMeetApproachArrivalLocked(now time.Time) {
+ seen := make(map[string]struct{})
+ for id, hm := range e.movements {
+ if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
+ continue
+ }
+ if hm.Excursion.Phase != model.ExcursionOut {
+ continue
+ }
+ pid := hm.Excursion.HeroMeetPartnerID
+ if pid == 0 {
+ continue
+ }
+ lo, hi := heroMeetOrderedPair(id, pid)
+ key := fmt.Sprintf("%d_%d", lo, hi)
+ if _, dup := seen[key]; dup {
+ continue
+ }
+ seen[key] = struct{}{}
+ a := e.movements[lo]
+ b := e.movements[hi]
+ if a == nil || b == nil {
+ continue
+ }
+ if !heroMeetHeroNearAttractor(a) || !heroMeetHeroNearAttractor(b) {
+ continue
+ }
+ a.Excursion.Phase = model.ExcursionPhaseHeroMeet
+ b.Excursion.Phase = model.ExcursionPhaseHeroMeet
+ a.Excursion.AttractorSet = false
+ b.Excursion.AttractorSet = false
+ e.transitionHeroMeetDialogueTimersLocked(lo, hi, now)
+ cfg := tuning.Get()
+ linger := cfg.HeroMeetPartnerLingerMs
+ if linger <= 0 {
+ linger = tuning.DefaultValues().HeroMeetPartnerLingerMs
+ }
+ e.pushHeroMeetStartLocked(lo, linger, "meet")
+ e.pushHeroMeetStartLocked(hi, linger, "meet")
+ e.persistHeroPairAfterMeetChangeLocked(lo, hi)
+ }
+}
+
+func heroMeetHeroNearReturnPoint(hm *HeroMovement) bool {
+ if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || hm.Excursion.Phase != model.ExcursionReturn {
+ return false
+ }
+ eps := ExcursionArrivalEpsilonWorld()
+ dx := hm.Excursion.StartX - hm.CurrentX
+ dy := hm.Excursion.StartY - hm.CurrentY
+ return math.Hypot(dx, dy) <= eps
+}
+
+// checkHeroMeetReturnArrivalLocked clears the meet when both heroes return to pre-meet positions.
+func (e *Engine) checkHeroMeetReturnArrivalLocked(now time.Time) {
+ seen := make(map[string]struct{})
+ for id, hm := range e.movements {
+ if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
+ continue
+ }
+ if hm.Excursion.Phase != model.ExcursionReturn {
+ continue
+ }
+ pid := hm.Excursion.HeroMeetPartnerID
+ if pid == 0 {
+ continue
+ }
+ lo, hi := heroMeetOrderedPair(id, pid)
+ key := fmt.Sprintf("%d_%d", lo, hi)
+ if _, dup := seen[key]; dup {
+ continue
+ }
+ seen[key] = struct{}{}
+ a := e.movements[lo]
+ b := e.movements[hi]
+ if a == nil || b == nil {
+ continue
+ }
+ if !heroMeetHeroNearReturnPoint(a) || !heroMeetHeroNearReturnPoint(b) {
+ continue
+ }
+ for _, hm2 := range []*HeroMovement{a, b} {
+ hm2.clearHeroMeetResumeWalking(now)
+ hm2.SyncToHero()
+ if e.sender != nil && hm2.Hero != nil {
+ hm2.Hero.EnsureGearMap()
+ hm2.Hero.RefreshDerivedCombatStats(now)
+ e.sender.SendToHero(hm2.HeroID, "hero_state", hm2.Hero)
+ e.sender.SendToHero(hm2.HeroID, "hero_move", hm2.MovePayload(now))
+ if route := hm2.RoutePayload(); route != nil {
+ e.sender.SendToHero(hm2.HeroID, "route_assigned", route)
+ }
+ }
+ }
+ if e.heroStore != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ if a.Hero != nil {
+ _ = e.heroStore.Save(ctx, a.Hero)
+ }
+ if b.Hero != nil {
+ _ = e.heroStore.Save(ctx, b.Hero)
+ }
+ cancel()
+ }
+ }
+}
+
+// teleportHeroTowardWorldPoint moves hm to lie `sep` world units from (tx,ty) along the segment toward hm (only if farther than sep).
+func teleportHeroTowardWorldPoint(hm *HeroMovement, tx, ty, sep float64, heroStore *storage.HeroStore) {
+ if hm == nil || sep <= 0 {
+ return
+ }
+ ox, oy := hm.CurrentX, hm.CurrentY
+ vx, vy := ox-tx, oy-ty
+ d := math.Hypot(vx, vy)
+ if d < 1e-6 {
+ return
+ }
+ if d <= sep {
+ return
+ }
+ scale := sep / d
+ hm.CurrentX = tx + vx*scale
+ hm.CurrentY = ty + vy*scale
+ hm.SyncToHero()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := heroStore.Save(ctx, hm.Hero); err != nil {
+ return
+ }
+}
+
+// BeginHeroMeetPairLocked starts a meet between two resident heroes. Caller must hold e.mu.
+// On failure, reason is a stable code for logs and admin API (not shown to players).
+func (e *Engine) BeginHeroMeetPairLocked(now time.Time, idA, idB int64) (ok bool, reason string) {
+ if e.roadGraph == nil {
+ return false, "road_graph_nil"
+ }
+ if idA == idB {
+ return false, "same_hero"
+ }
+ ha, okA := e.movements[idA]
+ hb, okB := e.movements[idB]
+ if !okA || !okB || ha == nil || hb == nil || ha.Hero == nil || hb.Hero == nil {
+ return false, "movement_or_hero_nil"
+ }
+ if _, inA := e.combats[idA]; inA {
+ return false, "hero_a_in_combat"
+ }
+ if _, inB := e.combats[idB]; inB {
+ return false, "hero_b_in_combat"
+ }
+ if ha.State != model.StateWalking || hb.State != model.StateWalking {
+ return false, "not_both_walking"
+ }
+ if ha.Excursion.Active() || hb.Excursion.Active() {
+ return false, "excursion_already_active"
+ }
+ if !ha.WanderingMerchantDeadline.IsZero() || !hb.WanderingMerchantDeadline.IsZero() {
+ return false, "wandering_merchant_pending"
+ }
+ x1, y1 := ha.worldPositionAt(now)
+ x2, y2 := hb.worldPositionAt(now)
+ if e.roadGraph.HeroInTownAt(x1, y1) || e.roadGraph.HeroInTownAt(x2, y2) {
+ return false, "hero_position_in_town_radius"
+ }
+
+ cfg := tuning.Get()
+ offlineBudget := randomHeroMeetOfflineDuration(cfg)
+ offlineMs := offlineBudget.Milliseconds()
+ if offlineMs < 0 {
+ offlineMs = 0
+ }
+
+ anchorX := (x1 + x2) / 2
+ anchorY := (y1 + y2) / 2
+ off := cfg.HeroMeetStandHalfOffsetWorld
+ if off <= 0 {
+ off = tuning.DefaultValues().HeroMeetStandHalfOffsetWorld
+ }
+ ax, ay := anchorX-off, anchorY
+ bx, by := anchorX+off, anchorY
+
+ lo, _ := heroMeetOrderedPair(idA, idB)
+ leader := ha
+ follower := hb
+ if idA != lo {
+ leader, follower = hb, ha
+ }
+
+ base := model.ExcursionSession{
+ Kind: model.ExcursionKindHeroMeet,
+ Phase: model.ExcursionOut,
+ StartedAt: now,
+ HeroMeetAnchorX: anchorX,
+ HeroMeetAnchorY: anchorY,
+ HeroMeetTurnHeroID: lo,
+ HeroMeetAutoLineIdx: 0,
+ HeroMeetOfflineRemainingMs: offlineMs,
+ }
+
+ leader.Excursion = base
+ leader.Excursion.HeroMeetPartnerID = follower.HeroID
+ leader.Excursion.RoadFreezeWaypoint = leader.WaypointIndex
+ leader.Excursion.RoadFreezeFraction = leader.WaypointFraction
+ leader.Excursion.StartX = leader.CurrentX
+ leader.Excursion.StartY = leader.CurrentY
+ if leader.HeroID == idA {
+ leader.Excursion.AttractorX, leader.Excursion.AttractorY = ax, ay
+ } else {
+ leader.Excursion.AttractorX, leader.Excursion.AttractorY = bx, by
+ }
+ leader.Excursion.AttractorSet = true
+
+ follower.Excursion = base
+ follower.Excursion.HeroMeetPartnerID = leader.HeroID
+ follower.Excursion.RoadFreezeWaypoint = follower.WaypointIndex
+ follower.Excursion.RoadFreezeFraction = follower.WaypointFraction
+ follower.Excursion.StartX = follower.CurrentX
+ follower.Excursion.StartY = follower.CurrentY
+ if follower.HeroID == idA {
+ follower.Excursion.AttractorX, follower.Excursion.AttractorY = ax, ay
+ } else {
+ follower.Excursion.AttractorX, follower.Excursion.AttractorY = bx, by
+ }
+ follower.Excursion.AttractorSet = true
+
+ e.syncHeroMeetPartnerExcursion(leader, follower)
+
+ e.heroMeetLastRoll[idA] = now
+ e.heroMeetLastRoll[idB] = now
+
+ e.persistHeroPairAfterMeetChangeLocked(idA, idB)
+
+ lingerOut := cfg.HeroMeetPartnerLingerMs
+ if lingerOut <= 0 {
+ lingerOut = tuning.DefaultValues().HeroMeetPartnerLingerMs
+ }
+ e.pushHeroMeetStartLocked(idA, lingerOut, "out")
+ e.pushHeroMeetStartLocked(idB, lingerOut, "out")
+ return true, ""
+}
+
+// pushHeroMeetStartLocked sends hero_meet_start. meetPhase is "out" (approach) or "meet" (dialogue).
+func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase string) {
+ hm, ok := e.movements[heroID]
+ if !ok || hm == nil || hm.Hero == nil || e.sender == nil {
+ return
+ }
+ if hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
+ return
+ }
+ switch meetPhase {
+ case "out":
+ if hm.Excursion.Phase != model.ExcursionOut {
+ return
+ }
+ case "meet":
+ if hm.Excursion.Phase != model.ExcursionPhaseHeroMeet {
+ return
+ }
+ default:
+ return
+ }
+ pid := hm.Excursion.HeroMeetPartnerID
+ ph, okp := e.movements[pid]
+ if !okp || ph == nil || ph.Hero == nil {
+ return
+ }
+ // Frozen meet stand: sync partner model so snapshot matches DB / hero_move (no drift during session).
+ ph.SyncToHero()
+ hm.SyncToHero()
+ px, py := ph.Hero.PositionX, ph.Hero.PositionY
+ partner := model.HeroMeetPartnerSnapshot{
+ ID: ph.Hero.ID,
+ Name: ph.Hero.Name,
+ Level: ph.Hero.Level,
+ PositionX: px,
+ PositionY: py,
+ }
+ anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid)
+ var promptEnds *time.Time
+ if meetPhase == "meet" && anyOnline && hm.Excursion.HeroMeetSubPhase == model.HeroMeetSubPrompt {
+ t := hm.Excursion.HeroMeetPromptUntil
+ promptEnds = &t
+ }
+ hm.Hero.EnsureGearMap()
+ hm.Hero.RefreshDerivedCombatStats(time.Now())
+ e.sender.SendToHero(heroID, "hero_state", hm.Hero)
+ e.sender.SendToHero(heroID, "hero_meet_start", model.HeroMeetStartPayload{
+ Partner: partner,
+ AnySideOnline: anyOnline,
+ PromptEndsAt: promptEnds,
+ PartnerLingerMs: lingerMs,
+ MeetPhase: meetPhase,
+ })
+}
+
+func (e *Engine) persistHeroPairAfterMeetChangeLocked(a, b int64) {
+ if e.heroStore == nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ for _, id := range []int64{a, b} {
+ hm := e.movements[id]
+ if hm == nil || hm.Hero == nil {
+ continue
+ }
+ hm.SyncToHero()
+ _ = e.heroStore.Save(ctx, hm.Hero)
+ }
+}
+
+// EndHeroMeetPairLocked ends the session for both heroes. Caller must hold e.mu.
+// user_end while in dialogue starts a return walk to pre-meet positions; abrupt reasons snap back to the road immediately.
+func (e *Engine) EndHeroMeetPairLocked(lo, hi int64, now time.Time, reason string) {
+ ha, okA := e.movements[lo]
+ hb, okB := e.movements[hi]
+ if !okA || !okB {
+ return
+ }
+ cfg := tuning.Get()
+ linger := cfg.HeroMeetPartnerLingerMs
+ if linger <= 0 {
+ linger = tuning.DefaultValues().HeroMeetPartnerLingerMs
+ }
+ endPayload := model.HeroMeetEndPayload{Reason: reason, PartnerLingerMs: linger}
+
+ userEndEarly := reason == "user_end" && ha.Excursion.Kind == model.ExcursionKindHeroMeet &&
+ (ha.Excursion.Phase != model.ExcursionPhaseHeroMeet || hb.Excursion.Phase != model.ExcursionPhaseHeroMeet)
+ abruptEnd := reason == "partner_gone" || reason == "offline_timer" || userEndEarly
+
+ if abruptEnd {
+ for _, hm := range []*HeroMovement{ha, hb} {
+ if hm == nil || hm.Hero == nil {
+ continue
+ }
+ if hm.Excursion.Kind != model.ExcursionKindHeroMeet {
+ continue
+ }
+ hm.clearHeroMeetResumeWalking(now)
+ hm.SyncToHero()
+ if e.sender != nil {
+ hm.Hero.EnsureGearMap()
+ hm.Hero.RefreshDerivedCombatStats(now)
+ e.sender.SendToHero(hm.HeroID, "hero_meet_end", endPayload)
+ e.sender.SendToHero(hm.HeroID, "hero_state", hm.Hero)
+ e.sender.SendToHero(hm.HeroID, "hero_move", hm.MovePayload(now))
+ if route := hm.RoutePayload(); route != nil {
+ e.sender.SendToHero(hm.HeroID, "route_assigned", route)
+ }
+ }
+ }
+ if e.heroStore != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = e.heroStore.Save(ctx, ha.Hero)
+ _ = e.heroStore.Save(ctx, hb.Hero)
+ }
+ return
+ }
+
+ for _, hm := range []*HeroMovement{ha, hb} {
+ if hm == nil || hm.Hero == nil {
+ continue
+ }
+ if hm.Excursion.Kind != model.ExcursionKindHeroMeet {
+ continue
+ }
+ hm.Excursion.Phase = model.ExcursionReturn
+ hm.Excursion.AttractorX = hm.Excursion.StartX
+ hm.Excursion.AttractorY = hm.Excursion.StartY
+ hm.Excursion.AttractorSet = true
+ hm.SyncToHero()
+ if e.sender != nil {
+ hm.Hero.EnsureGearMap()
+ hm.Hero.RefreshDerivedCombatStats(now)
+ e.sender.SendToHero(hm.HeroID, "hero_meet_end", endPayload)
+ e.sender.SendToHero(hm.HeroID, "hero_state", hm.Hero)
+ e.sender.SendToHero(hm.HeroID, "hero_move", hm.MovePayload(now))
+ if route := hm.RoutePayload(); route != nil {
+ e.sender.SendToHero(hm.HeroID, "route_assigned", route)
+ }
+ }
+ }
+ if la, lb := e.movements[lo], e.movements[hi]; la != nil && lb != nil {
+ e.syncHeroMeetPartnerExcursion(la, lb)
+ }
+ if e.heroStore != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = e.heroStore.Save(ctx, ha.Hero)
+ _ = e.heroStore.Save(ctx, hb.Hero)
+ }
+}
+
+func (e *Engine) tryRandomHeroMeetProximityLocked(now time.Time) {
+ if e.roadGraph == nil || len(e.movements) < 2 {
+ return
+ }
+ cfg := tuning.Get()
+ radius := cfg.HeroMeetRadiusWorld
+ if radius <= 0 {
+ radius = tuning.DefaultValues().HeroMeetRadiusWorld
+ }
+ ch := cfg.HeroMeetChancePerTick
+ if ch <= 0 {
+ ch = tuning.DefaultValues().HeroMeetChancePerTick
+ }
+ cd := time.Duration(cfg.HeroMeetCooldownMs) * time.Millisecond
+ if cd <= 0 {
+ cd = time.Duration(tuning.DefaultValues().HeroMeetCooldownMs) * time.Millisecond
+ }
+
+ ids := make([]int64, 0, len(e.movements))
+ for id := range e.movements {
+ ids = append(ids, id)
+ }
+ for i := 0; i < len(ids); i++ {
+ for j := i + 1; j < len(ids); j++ {
+ a, b := ids[i], ids[j]
+ lo, hi := heroMeetOrderedPair(a, b)
+ ha := e.movements[lo]
+ hb := e.movements[hi]
+ if ha == nil || hb == nil {
+ continue
+ }
+ if now.Sub(e.heroMeetLastRoll[lo]) < cd || now.Sub(e.heroMeetLastRoll[hi]) < cd {
+ continue
+ }
+ x1, y1 := ha.worldPositionAt(now)
+ x2, y2 := hb.worldPositionAt(now)
+ dx, dy := x1-x2, y1-y2
+ if dx*dx+dy*dy > radius*radius {
+ continue
+ }
+ if rand.Float64() >= ch {
+ continue
+ }
+ if started, _ := e.BeginHeroMeetPairLocked(now, lo, hi); started {
+ return
+ }
+ }
+ }
+}
+
+func (e *Engine) processHeroMeetTickLocked(now time.Time) {
+ if len(e.movements) < 2 {
+ return
+ }
+ seen := make(map[string]struct{})
+ for id, hm := range e.movements {
+ if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
+ continue
+ }
+ partnerID := hm.Excursion.HeroMeetPartnerID
+ if partnerID == 0 {
+ continue
+ }
+ lo, hi := heroMeetOrderedPair(id, partnerID)
+ key := fmt.Sprintf("%d_%d", lo, hi)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+
+ leader := e.movements[lo]
+ partner := e.movements[hi]
+ if leader == nil || partner == nil {
+ e.EndHeroMeetPairLocked(lo, hi, now, "partner_gone")
+ continue
+ }
+
+ ex := &leader.Excursion
+ if ex.Phase != model.ExcursionPhaseHeroMeet {
+ e.syncHeroMeetPartnerExcursion(leader, partner)
+ continue
+ }
+
+ anyOnline := e.heroMeetAnySubscriberOnline(lo, hi)
+ cfg := tuning.Get()
+ autoInt := cfg.HeroMeetAutoLineIntervalMs
+ if autoInt <= 0 {
+ autoInt = tuning.DefaultValues().HeroMeetAutoLineIntervalMs
+ }
+
+ // Offline wall timer
+ if anyOnline {
+ if ex.HeroMeetOfflineTimerRunning {
+ rem := ex.HeroMeetOfflineDeadline.Sub(now)
+ if rem < 0 {
+ rem = 0
+ }
+ ex.HeroMeetOfflineRemainingMs = rem.Milliseconds()
+ ex.HeroMeetOfflineTimerRunning = false
+ }
+ } else {
+ if !ex.HeroMeetOfflineTimerRunning {
+ ex.HeroMeetOfflineTimerRunning = true
+ ex.HeroMeetOfflineDeadline = now.Add(time.Duration(ex.HeroMeetOfflineRemainingMs) * time.Millisecond)
+ }
+ if now.After(ex.HeroMeetOfflineDeadline) {
+ e.EndHeroMeetPairLocked(lo, hi, now, "offline_timer")
+ continue
+ }
+ }
+
+ // Scripted auto lines: off if both players have WS; both offline → alternate; one offline → only that hero speaks.
+ loOn := e.heroMeetPlayerOnline(lo)
+ hiOn := e.heroMeetPlayerOnline(hi)
+ emitAuto := !(loOn && hiOn)
+ if emitAuto && !now.Before(ex.HeroMeetNextAutoAt) {
+ e.emitHeroMeetAutoLineLocked(lo, hi, now)
+ ex.HeroMeetNextAutoAt = now.Add(time.Duration(autoInt) * time.Millisecond)
+ }
+
+ e.syncHeroMeetPartnerExcursion(leader, partner)
+ }
+}
+
+func (e *Engine) emitHeroMeetAutoLineLocked(lo, hi int64, now time.Time) {
+ leader := e.movements[lo]
+ if leader == nil || leader.Hero == nil {
+ return
+ }
+ if leader.Excursion.Phase != model.ExcursionPhaseHeroMeet {
+ return
+ }
+ ex := &leader.Excursion
+ loOn := e.heroMeetPlayerOnline(lo)
+ hiOn := e.heroMeetPlayerOnline(hi)
+ if loOn && hiOn {
+ return
+ }
+
+ var speakerID int64
+ if !loOn && !hiOn {
+ speakerID = ex.HeroMeetTurnHeroID
+ if speakerID != lo && speakerID != hi {
+ speakerID = lo
+ }
+ } else {
+ // Exactly one online: scripted lines only from the offline hero.
+ if !loOn {
+ speakerID = lo
+ } else {
+ speakerID = hi
+ }
+ }
+
+ sh := e.movements[speakerID]
+ if sh == nil || sh.Hero == nil {
+ return
+ }
+ lineKey := model.HeroMeetAutoPhraseKey(ex.HeroMeetAutoLineIdx)
+ ex.HeroMeetAutoLineIdx++
+
+ if !loOn && !hiOn {
+ if speakerID == lo {
+ ex.HeroMeetTurnHeroID = hi
+ } else {
+ ex.HeroMeetTurnHeroID = lo
+ }
+ } else {
+ // Mixed: point turn at the online hero so when they disconnect, alternation resumes naturally.
+ if speakerID == lo {
+ ex.HeroMeetTurnHeroID = hi
+ } else {
+ ex.HeroMeetTurnHeroID = lo
+ }
+ }
+
+ name := sh.Hero.Name
+ if e.adventureLog != nil {
+ line := model.AdventureLogLine{
+ Event: &model.AdventureLogEvent{
+ Code: model.LogPhraseHeroMeetScripted,
+ Args: map[string]any{"speaker": name, "lineKey": lineKey},
+ },
+ }
+ e.adventureLog(lo, line)
+ e.adventureLog(hi, line)
+ }
+ if e.sender != nil {
+ payload := model.HeroMeetLinePayload{
+ FromHeroID: speakerID,
+ Kind: "scripted",
+ LineKey: lineKey,
+ }
+ e.sender.SendToHero(lo, "hero_meet_line", payload)
+ e.sender.SendToHero(hi, "hero_meet_line", payload)
+ }
+ partner := e.movements[hi]
+ if partner != nil {
+ e.syncHeroMeetPartnerExcursion(leader, partner)
+ }
+}
+
+// pushHeroMeetIfActiveLocked sends hero_meet_start when the hero is in dialogue meet phase. Caller holds e.mu.
+func (e *Engine) pushHeroMeetIfActiveLocked(heroID int64) {
+ hm := e.movements[heroID]
+ if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
+ return
+ }
+ cfg := tuning.Get()
+ linger := cfg.HeroMeetPartnerLingerMs
+ if linger <= 0 {
+ linger = tuning.DefaultValues().HeroMeetPartnerLingerMs
+ }
+ if hm.Excursion.Phase != "" {
+ e.pushHeroMeetStartLocked(heroID, linger, string(hm.Excursion.Phase))
+ return
+ }
+ if e.sender != nil && hm.Hero != nil {
+ hm.SyncToHero()
+ e.sender.SendToHero(heroID, "hero_state", hm.Hero)
+ e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(time.Now()))
+ }
+}
+
+// ApplyAdminStartHeroMeet teleports other to primary and starts a meet (online heroes only).
+// reason is set when ok is false (for logs and admin JSON detail).
+func (e *Engine) ApplyAdminStartHeroMeet(primaryID, otherID int64) (hero *model.Hero, ok bool, reason string) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ logReject := func(r string, extra ...any) (*model.Hero, bool, string) {
+ if e.logger != nil {
+ args := append([]any{"reason", r, "primary_id", primaryID, "other_id", otherID}, extra...)
+ e.logger.Warn("admin hero_meet start rejected", args...)
+ }
+ return nil, false, r
+ }
+ if primaryID == otherID {
+ return logReject("same_hero")
+ }
+ hp := e.movements[primaryID]
+ ho := e.movements[otherID]
+ if hp == nil || ho == nil || hp.Hero == nil || ho.Hero == nil {
+ return logReject("movement_not_in_engine", "has_primary_movement", hp != nil, "has_other_movement", ho != nil)
+ }
+ if _, ok := e.combats[primaryID]; ok {
+ return logReject("primary_in_combat")
+ }
+ if _, ok := e.combats[otherID]; ok {
+ return logReject("other_in_combat")
+ }
+ now := time.Now()
+ px, py := hp.worldPositionAt(now)
+ cfg := tuning.Get()
+ sep := cfg.HeroMeetAdminSnapSeparationWorld
+ if sep <= 0 {
+ sep = tuning.DefaultValues().HeroMeetAdminSnapSeparationWorld
+ }
+
+ ho.DestinationTownID = hp.DestinationTownID
+ ho.CurrentTownID = hp.CurrentTownID
+ ho.WaypointIndex = hp.WaypointIndex
+ ho.WaypointFraction = hp.WaypointFraction
+ ho.Excursion.RoadFreezeFraction = hp.Excursion.RoadFreezeFraction
+ ho.Excursion.RoadFreezeWaypoint = hp.Excursion.RoadFreezeWaypoint
+ teleportHeroTowardWorldPoint(ho, px, py, sep, e.heroStore)
+
+ if started, r := e.BeginHeroMeetPairLocked(now, primaryID, otherID); !started {
+ if e.logger != nil {
+ e.logger.Warn("admin hero_meet start rejected after teleport snap",
+ "reason", r,
+ "primary_id", primaryID,
+ "other_id", otherID,
+ "primary_state", hp.State,
+ "other_state", ho.State,
+ "primary_excursion_active", hp.Excursion.Active(),
+ "other_excursion_active", ho.Excursion.Active(),
+ "primary_wm_deadline_set", !hp.WanderingMerchantDeadline.IsZero(),
+ "other_wm_deadline_set", !ho.WanderingMerchantDeadline.IsZero(),
+ )
+ }
+ return nil, false, r
+ }
+ out := e.movements[primaryID]
+ if out == nil || out.Hero == nil {
+ if e.logger != nil {
+ e.logger.Error("admin hero_meet inconsistent: primary missing after begin", "primary_id", primaryID, "other_id", otherID)
+ }
+ return nil, false, "internal_primary_missing_after_begin"
+ }
+ return out.Hero, true, ""
+}
+
+// NotifyHeroMeetAfterWSConnect pushes meet UI state after WS connect (dialogue start or approach snapshot).
+func (e *Engine) NotifyHeroMeetAfterWSConnect(heroID int64) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ hm := e.movements[heroID]
+ if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
+ return
+ }
+ e.pushHeroMeetIfActiveLocked(heroID)
+ pid := hm.Excursion.HeroMeetPartnerID
+ if pid != 0 {
+ e.pushHeroMeetIfActiveLocked(pid)
+ }
+}
+
+func (e *Engine) handleHeroMeetSendMessage(msg IncomingMessage) {
+ var p model.HeroMeetSendMessagePayload
+ if err := json.Unmarshal(msg.Payload, &p); err != nil {
+ e.sendError(msg.HeroID, "invalid_payload", "invalid hero_meet_send_message")
+ return
+ }
+ text := trimHeroMeetMessage(p.Text)
+ maxR := tuning.Get().HeroMeetMessageMaxRunes
+ if maxR <= 0 {
+ maxR = tuning.DefaultValues().HeroMeetMessageMaxRunes
+ }
+ if utf8.RuneCountInString(text) > maxR {
+ e.sendError(msg.HeroID, "message_too_long", "message too long")
+ return
+ }
+ if text == "" {
+ e.sendError(msg.HeroID, "empty_message", "empty message")
+ return
+ }
+ if profanity.ChatMessageIsProfane(text) {
+ e.sendError(msg.HeroID, "profanity", "message rejected")
+ return
+ }
+
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ hm := e.movements[msg.HeroID]
+ if hm == nil || hm.Hero == nil {
+ e.sendError(msg.HeroID, "no_hero", "hero not active")
+ return
+ }
+ if hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
+ e.sendError(msg.HeroID, "not_in_meet", "not in hero meet")
+ return
+ }
+ if hm.Excursion.Phase != model.ExcursionPhaseHeroMeet {
+ e.sendError(msg.HeroID, "not_in_dialogue", "meet dialogue not active")
+ return
+ }
+ if !e.heroMeetAnySubscriberOnline(msg.HeroID, hm.Excursion.HeroMeetPartnerID) {
+ e.sendError(msg.HeroID, "offline", "cannot chat while offline")
+ return
+ }
+ cd := time.Duration(tuning.Get().HeroMeetMessageCooldownMs) * time.Millisecond
+ if cd <= 0 {
+ cd = time.Duration(tuning.DefaultValues().HeroMeetMessageCooldownMs) * time.Millisecond
+ }
+ now := time.Now()
+ if t := e.heroMeetLastMsg[msg.HeroID]; !t.IsZero() && now.Sub(t) < cd {
+ e.sendError(msg.HeroID, "rate_limited", "slow down")
+ return
+ }
+ e.heroMeetLastMsg[msg.HeroID] = now
+
+ pid := hm.Excursion.HeroMeetPartnerID
+ lo, hi := heroMeetOrderedPair(msg.HeroID, pid)
+ leader := e.movements[lo]
+ partner := e.movements[hi]
+ if leader == nil || partner == nil {
+ return
+ }
+ leader.Excursion.HeroMeetHadPlayerMessage = true
+ e.syncHeroMeetPartnerExcursion(leader, partner)
+
+ name := hm.Hero.Name
+ if e.adventureLog != nil {
+ line := model.AdventureLogLine{
+ Event: &model.AdventureLogEvent{
+ Code: model.LogPhraseHeroMeetPlayerSaid,
+ Args: map[string]any{"speaker": name, "text": text},
+ },
+ }
+ e.adventureLog(lo, line)
+ e.adventureLog(hi, line)
+ }
+ payload := model.HeroMeetLinePayload{FromHeroID: msg.HeroID, Kind: "player", Text: text}
+ if e.sender != nil {
+ e.sender.SendToHero(lo, "hero_meet_line", payload)
+ e.sender.SendToHero(hi, "hero_meet_line", payload)
+ }
+}
+
+func (e *Engine) handleHeroMeetEndConversation(msg IncomingMessage) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ hm := e.movements[msg.HeroID]
+ if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
+ return
+ }
+ pid := hm.Excursion.HeroMeetPartnerID
+ if pid == 0 {
+ return
+ }
+ if !e.heroMeetAnySubscriberOnline(msg.HeroID, pid) {
+ return
+ }
+ lo, hi := heroMeetOrderedPair(msg.HeroID, pid)
+ e.EndHeroMeetPairLocked(lo, hi, time.Now(), "user_end")
+}
+
+func trimHeroMeetMessage(s string) string {
+ // trim ASCII whitespace; keep inner spaces
+ for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') {
+ s = s[1:]
+ }
+ for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
+ s = s[:len(s)-1]
+ }
+ return s
+}
diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go
index d6f7b3c..885059f 100644
--- a/backend/internal/game/movement.go
+++ b/backend/internal/game/movement.go
@@ -173,6 +173,13 @@ type townPausePersistSignature struct {
InTownVisitName string
InTownVisitType string
InTownLastNPCLinger time.Time
+
+ // Hero meet (coarse — avoids persisting every auto line tick; engine syncs partner each tick).
+ HeroMeetPartnerID int64
+ HeroMeetSubPhase string
+ HeroMeetOfflineTimerRunning bool
+ HeroMeetOfflineRemainingMs int64
+ HeroMeetAutoLineIdx int
}
func npcQueueFingerprint(q []int64) uint64 {
@@ -902,6 +909,11 @@ func excursionArrivalEpsilon() float64 {
return eps
}
+// ExcursionArrivalEpsilonWorld is exported for hero_meet pairing checks outside movement tick helpers.
+func ExcursionArrivalEpsilonWorld() float64 {
+ return excursionArrivalEpsilon()
+}
+
// stepTowardWorldPoint moves CurrentX/Y toward (tx, ty) at speed (world units per second).
// Uses the same arrival epsilon as excursion attractors.
func (hm *HeroMovement) stepTowardWorldPoint(dt float64, tx, ty, speed float64) bool {
@@ -1340,6 +1352,8 @@ func (hm *HeroMovement) SyncToHero() {
hm.Hero.TownTourPhase = hm.Excursion.TownTourPhase
hm.Hero.TownTourNpcID = hm.Excursion.TownTourNpcID
hm.Hero.TownTourExitPending = hm.Excursion.TownExitPending
+ } else if hm.Excursion.Kind == model.ExcursionKindHeroMeet {
+ hm.Hero.ExcursionPhase = hm.Excursion.Phase
} else {
hm.Hero.ExcursionPhase = hm.Excursion.Phase
}
@@ -1432,6 +1446,14 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig.InTownVisitType = hm.TownVisitNPCType
sig.InTownLastNPCLinger = hm.TownLastNPCLingerUntil
}
+ if hm.Excursion.Kind == model.ExcursionKindHeroMeet && hm.Excursion.Active() {
+ s := hm.Excursion
+ sig.HeroMeetPartnerID = s.HeroMeetPartnerID
+ sig.HeroMeetSubPhase = s.HeroMeetSubPhase
+ sig.HeroMeetOfflineTimerRunning = s.HeroMeetOfflineTimerRunning
+ sig.HeroMeetOfflineRemainingMs = s.HeroMeetOfflineRemainingMs
+ sig.HeroMeetAutoLineIdx = s.HeroMeetAutoLineIdx
+ }
return sig
}
@@ -1571,6 +1593,29 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
ep.TownRestUntil = &t
}
}
+ if s.Kind == model.ExcursionKindHeroMeet {
+ ep.HeroMeetPartnerID = s.HeroMeetPartnerID
+ ep.HeroMeetSubPhase = s.HeroMeetSubPhase
+ ep.HeroMeetTurnHeroID = s.HeroMeetTurnHeroID
+ ep.HeroMeetHadPlayerMessage = s.HeroMeetHadPlayerMessage
+ ep.HeroMeetOfflineTimerRunning = s.HeroMeetOfflineTimerRunning
+ ep.HeroMeetOfflineRemainingMs = s.HeroMeetOfflineRemainingMs
+ ep.HeroMeetAutoLineIdx = s.HeroMeetAutoLineIdx
+ ep.HeroMeetAnchorX = s.HeroMeetAnchorX
+ ep.HeroMeetAnchorY = s.HeroMeetAnchorY
+ if !s.HeroMeetPromptUntil.IsZero() {
+ t := s.HeroMeetPromptUntil
+ ep.HeroMeetPromptUntil = &t
+ }
+ if !s.HeroMeetNextAutoAt.IsZero() {
+ t := s.HeroMeetNextAutoAt
+ ep.HeroMeetNextAutoAt = &t
+ }
+ if !s.HeroMeetOfflineDeadline.IsZero() {
+ t := s.HeroMeetOfflineDeadline
+ ep.HeroMeetOfflineDeadline = &t
+ }
+ }
return ep
}
@@ -1685,6 +1730,26 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
hm.Excursion.TownRestUntil = *ep.TownRestUntil
}
}
+ if ep.Kind == string(model.ExcursionKindHeroMeet) {
+ hm.Excursion.HeroMeetPartnerID = ep.HeroMeetPartnerID
+ hm.Excursion.HeroMeetSubPhase = ep.HeroMeetSubPhase
+ hm.Excursion.HeroMeetTurnHeroID = ep.HeroMeetTurnHeroID
+ hm.Excursion.HeroMeetHadPlayerMessage = ep.HeroMeetHadPlayerMessage
+ hm.Excursion.HeroMeetOfflineTimerRunning = ep.HeroMeetOfflineTimerRunning
+ hm.Excursion.HeroMeetOfflineRemainingMs = ep.HeroMeetOfflineRemainingMs
+ hm.Excursion.HeroMeetAutoLineIdx = ep.HeroMeetAutoLineIdx
+ hm.Excursion.HeroMeetAnchorX = ep.HeroMeetAnchorX
+ hm.Excursion.HeroMeetAnchorY = ep.HeroMeetAnchorY
+ if ep.HeroMeetPromptUntil != nil {
+ hm.Excursion.HeroMeetPromptUntil = *ep.HeroMeetPromptUntil
+ }
+ if ep.HeroMeetNextAutoAt != nil {
+ hm.Excursion.HeroMeetNextAutoAt = *ep.HeroMeetNextAutoAt
+ }
+ if ep.HeroMeetOfflineDeadline != nil {
+ hm.Excursion.HeroMeetOfflineDeadline = *ep.HeroMeetOfflineDeadline
+ }
+ }
}
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
@@ -1886,6 +1951,28 @@ func (hm *HeroMovement) endExcursion(now time.Time) {
}
}
+// clearHeroMeetResumeWalking snaps the hero back to the frozen road point and clears the meet excursion.
+func (hm *HeroMovement) clearHeroMeetResumeWalking(now time.Time) {
+ if hm == nil {
+ return
+ }
+ hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint
+ hm.WaypointFraction = hm.Excursion.RoadFreezeFraction
+ hm.Excursion = model.ExcursionSession{}
+ if hm.Road != nil && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
+ from := hm.Road.Waypoints[hm.WaypointIndex]
+ to := hm.Road.Waypoints[hm.WaypointIndex+1]
+ hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
+ hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
+ }
+ hm.State = model.StateWalking
+ if hm.Hero != nil {
+ hm.Hero.State = model.StateWalking
+ }
+ hm.LastMoveTick = now
+ hm.refreshSpeed(now)
+}
+
func (hm *HeroMovement) beginRoadsideRest(now time.Time) {
cfg := tuning.Get()
hm.State = model.StateResting
@@ -2165,6 +2252,12 @@ func ProcessSingleHeroMovementTick(
return
case model.StateWalking:
+ // Hero meet dialogue: frozen until return phase starts.
+ if hm.Excursion.Kind == model.ExcursionKindHeroMeet && hm.Excursion.Active() &&
+ hm.Excursion.Phase == model.ExcursionPhaseHeroMeet {
+ return
+ }
+
cfg := tuning.Get()
hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2
if hadNoRoad {
@@ -2197,6 +2290,23 @@ func ProcessSingleHeroMovementTick(
}
}
+ // --- Hero meet: walk to stand (out) or back to pre-meet point (return) ---
+ if hm.Excursion.Active() && hm.Excursion.Kind == model.ExcursionKindHeroMeet &&
+ (hm.Excursion.Phase == model.ExcursionOut || hm.Excursion.Phase == model.ExcursionReturn) {
+ dtHM := now.Sub(hm.LastMoveTick).Seconds()
+ if dtHM <= 0 {
+ dtHM = movementTickRate().Seconds()
+ }
+ hm.refreshSpeed(now)
+ _ = hm.stepTowardAttractor(now, dtHM)
+ hm.LastMoveTick = now
+ if sender != nil {
+ sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
+ }
+ hm.SyncToHero()
+ return
+ }
+
// --- Active adventure excursion (attractor movement while walking) ---
if hm.Excursion.Active() && hm.Excursion.Kind == model.ExcursionKindAdventure {
dtAdv := now.Sub(hm.LastMoveTick).Seconds()
diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go
index 916978d..9571a48 100644
--- a/backend/internal/game/offline_test.go
+++ b/backend/internal/game/offline_test.go
@@ -102,17 +102,17 @@ func TestBuildEnemyInstanceForLevel_XPPerLevelRampsFrom10(t *testing.T) {
XPPerLevel: 4,
IsElite: false,
}
- early := BuildEnemyInstanceForLevel(tmpl, 6)
+ early := BuildEnemyInstanceForLevel(tmpl, 6, nil)
if early.XPReward != 1 {
t.Fatalf("normal mob instance L6: want base XP only (no per-level ramp), got %d", early.XPReward)
}
- mid := BuildEnemyInstanceForLevel(tmpl, 12)
+ mid := BuildEnemyInstanceForLevel(tmpl, 12, nil)
if mid.XPReward <= 1 {
t.Fatalf("normal mob instance L12: want xp_per_level applied, got %d", mid.XPReward)
}
elite := tmpl
elite.IsElite = true
- el := BuildEnemyInstanceForLevel(elite, 5)
+ el := BuildEnemyInstanceForLevel(elite, 5, nil)
if el.XPReward <= 1 {
t.Fatalf("elite instance L5: want xp_per_level even before 10, got %d", el.XPReward)
}
@@ -163,7 +163,7 @@ func TestBuildEnemyInstanceForLevel_EncounterStatMultiplier(t *testing.T) {
Attack: 10,
Defense: 4,
}
- out := BuildEnemyInstanceForLevel(tmpl, 1)
+ out := BuildEnemyInstanceForLevel(tmpl, 1, nil)
if out.MaxHP != 100 || out.HP != 100 {
t.Fatalf("MaxHP/HP: got %d/%d want 100/100", out.MaxHP, out.HP)
}
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index 0c74160..5977f97 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -2138,6 +2138,54 @@ func (h *AdminHandler) TownTourApproachNPC(w http.ResponseWriter, r *http.Reques
h.writeAdminHeroDetail(w, heroAfter)
}
+// StartHeroMeet forces a paired hero meet: primary stays anchored, other teleports beside them.
+// POST /admin/heroes/{heroId}/start-hero-meet body: {"otherHeroId":123}
+func (h *AdminHandler) StartHeroMeet(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 {
+ OtherHeroID int64 `json:"otherHeroId"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.OtherHeroID <= 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: need {\"otherHeroId\": positive number}"})
+ return
+ }
+ if h.engine.GetMovements(heroID) == nil || h.engine.GetMovements(req.OtherHeroID) == nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "both heroes must be online (engine movement session)"})
+ return
+ }
+ out, ok, meetReason := h.engine.ApplyAdminStartHeroMeet(heroID, req.OtherHeroID)
+ if !ok || out == nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "cannot start hero meet",
+ "detail": meetReason,
+ })
+ return
+ }
+ out.RefreshDerivedCombatStats(time.Now())
+ if err := h.store.Save(r.Context(), out); err != nil {
+ h.logger.Error("admin: save after start-hero-meet", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
+ return
+ }
+ if other, err := h.store.GetByID(r.Context(), req.OtherHeroID); err == nil && other != nil {
+ _ = h.store.Save(r.Context(), other)
+ }
+ h.logger.Info("admin: start hero meet", "hero_id", heroID, "other_hero_id", req.OtherHeroID)
+ heroAfter, err := h.store.GetByID(r.Context(), heroID)
+ if err != nil || heroAfter == nil {
+ h.writeAdminHeroDetail(w, out)
+ return
+ }
+ h.writeAdminHeroDetail(w, heroAfter)
+}
+
// ForceLeaveTown is an alias for the unified stop-rest flow (see StopHeroRest).
// POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go
index bd0a5ea..abdb287 100644
--- a/backend/internal/handler/game.go
+++ b/backend/internal/handler/game.go
@@ -141,19 +141,21 @@ func resolveTelegramID(r *http.Request) (int64, bool) {
if id, ok := TelegramIDFromContext(r.Context()); ok {
return id, true
}
- // Dev fallback: accept telegramId query param.
- idStr := r.URL.Query().Get("telegramId")
- if idStr != "" {
- id, err := strconv.ParseInt(idStr, 10, 64)
- if err == nil {
- return id, true
- }
- }
- // Localhost fallback: default to telegram_id 1 for testing.
+
host := r.Host
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "192.168.0.53") {
+ // Dev fallback: accept telegramId query param.
+ idStr := r.URL.Query().Get("telegramId")
+ if idStr != "" {
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err == nil {
+ return id, true
+ }
+ }
+ // Localhost fallback: default to telegram_id 1 for testing.
return 1, true
}
+
return 0, false
}
@@ -313,17 +315,17 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return
}
- hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
- if err != nil {
- h.logger.Error("failed to get hero for revive", "telegram_id", telegramID, "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{
- "error": "failed to load hero",
+ if h.engine == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{
+ "error": "game engine unavailable",
})
return
}
+
+ hero := h.engine.LiveHeroByTelegramID(telegramID)
if hero == nil {
- writeJSON(w, http.StatusNotFound, map[string]string{
- "error": "hero not found",
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "no active hero session; connect to revive",
})
return
}
@@ -360,9 +362,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return
}
- if h.engine != nil {
- h.engine.ApplyAdminHeroRevive(hero)
- }
+ h.engine.ApplyAdminHeroRevive(hero)
h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP)
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}})
@@ -1631,7 +1631,14 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) {
radius = 2000
}
- nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, hero.PositionX, hero.PositionY, radius, 50)
+ posX, posY := hero.PositionX, hero.PositionY
+ if h.engine != nil {
+ if wx, wy, ok := h.engine.HeroWorldPositionForCombat(hero.ID); ok {
+ posX, posY = wx, wy
+ }
+ }
+
+ nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 50)
if err != nil {
h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
@@ -1640,6 +1647,10 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) {
return
}
+ if h.engine != nil {
+ h.engine.OverlayResidentWorldPositionsOnNearby(nearby)
+ }
+
writeJSON(w, http.StatusOK, map[string]any{
"heroes": nearby,
})
diff --git a/backend/internal/model/adventure_log_phrase_keys.go b/backend/internal/model/adventure_log_phrase_keys.go
index fdefa1b..b971c31 100644
--- a/backend/internal/model/adventure_log_phrase_keys.go
+++ b/backend/internal/model/adventure_log_phrase_keys.go
@@ -39,6 +39,8 @@ const (
LogPhraseCombatEnemyHit = "log.combat.enemy_hit"
LogPhraseCombatEnemyBlock = "log.combat.enemy_block"
LogPhraseCombatDebuffSuffix = "log.combat.debuff_suffix"
+ LogPhraseHeroMeetPlayerSaid = "log.hero_meet.player_said"
+ LogPhraseHeroMeetScripted = "log.hero_meet.scripted_line"
)
// Town visit line slugs per NPC kind (order = timed line 0..5). Unknown npcType uses generic slugs with key prefix "generic".
diff --git a/backend/internal/model/excursion.go b/backend/internal/model/excursion.go
index 39b6d02..0142ea5 100644
--- a/backend/internal/model/excursion.go
+++ b/backend/internal/model/excursion.go
@@ -22,8 +22,18 @@ const (
ExcursionKindRoadside ExcursionKind = "roadside"
ExcursionKindAdventure ExcursionKind = "adventure"
ExcursionKindTown ExcursionKind = "town"
+ ExcursionKindHeroMeet ExcursionKind = "hero_meet"
)
+// ExcursionPhaseHeroMeet: heroes stand still during a paired meet session.
+const ExcursionPhaseHeroMeet ExcursionPhase = "meet"
+
+// HeroMeetSubPrompt: online — wait for player text or HeroMeetPromptWindowMs then auto lines.
+const HeroMeetSubPrompt = "prompt"
+
+// HeroMeetSubAuto: alternating scripted lines every HeroMeetAutoLineInterval (tuning).
+const HeroMeetSubAuto = "auto"
+
// TownTourPhase is the sub-state machine while ExcursionKind == town (StateInTown).
type TownTourPhase string
@@ -86,6 +96,22 @@ type ExcursionSession struct {
TownTourDialogOpen bool
// Client has NPCInteraction panel open — shifts service deadline; with dialog shifts welcome too.
TownTourInteractionOpen bool
+
+ // --- Hero meet (Kind == ExcursionKindHeroMeet) ---
+ HeroMeetPartnerID int64
+ // SubPhase: HeroMeetSubPrompt | HeroMeetSubAuto
+ HeroMeetSubPhase string
+ HeroMeetPromptUntil time.Time
+ HeroMeetNextAutoAt time.Time
+ HeroMeetTurnHeroID int64
+ HeroMeetHadPlayerMessage bool
+ HeroMeetOfflineDeadline time.Time
+ HeroMeetOfflineTimerRunning bool
+ HeroMeetOfflineRemainingMs int64
+ HeroMeetAutoLineIdx int
+ // MeetAnchorX/Y: center; heroes placed at anchor ± small offset.
+ HeroMeetAnchorX float64
+ HeroMeetAnchorY float64
}
// Active reports whether an excursion session is in progress.
@@ -96,6 +122,17 @@ func (s *ExcursionSession) Active() bool {
if s.Kind == ExcursionKindTown {
return s.TownTourPhase != ""
}
+ if s.Kind == ExcursionKindHeroMeet {
+ if s.HeroMeetPartnerID == 0 {
+ return false
+ }
+ switch s.Phase {
+ case ExcursionOut, ExcursionPhaseHeroMeet, ExcursionReturn:
+ return true
+ default:
+ return false
+ }
+ }
return s.Phase != ExcursionNone
}
@@ -131,4 +168,18 @@ type ExcursionPersisted struct {
TownExitPending bool `json:"townExitPending,omitempty"`
TownTourDialogOpen bool `json:"townTourDialogOpen,omitempty"`
TownTourInteractionOpen bool `json:"townTourInteractionOpen,omitempty"`
+
+ // Hero meet (kind hero_meet)
+ HeroMeetPartnerID int64 `json:"heroMeetPartnerId,omitempty"`
+ HeroMeetSubPhase string `json:"heroMeetSubPhase,omitempty"`
+ HeroMeetPromptUntil *time.Time `json:"heroMeetPromptUntil,omitempty"`
+ HeroMeetNextAutoAt *time.Time `json:"heroMeetNextAutoAt,omitempty"`
+ HeroMeetTurnHeroID int64 `json:"heroMeetTurnHeroId,omitempty"`
+ HeroMeetHadPlayerMessage bool `json:"heroMeetHadPlayerMessage,omitempty"`
+ HeroMeetOfflineDeadline *time.Time `json:"heroMeetOfflineDeadline,omitempty"`
+ HeroMeetOfflineTimerRunning bool `json:"heroMeetOfflineTimerRunning,omitempty"`
+ HeroMeetOfflineRemainingMs int64 `json:"heroMeetOfflineRemainingMs,omitempty"`
+ HeroMeetAutoLineIdx int `json:"heroMeetAutoLineIdx,omitempty"`
+ HeroMeetAnchorX float64 `json:"heroMeetAnchorX,omitempty"`
+ HeroMeetAnchorY float64 `json:"heroMeetAnchorY,omitempty"`
}
diff --git a/backend/internal/model/hero_meet_lines.go b/backend/internal/model/hero_meet_lines.go
new file mode 100644
index 0000000..6d51263
--- /dev/null
+++ b/backend/internal/model/hero_meet_lines.go
@@ -0,0 +1,29 @@
+package model
+
+// HeroMeetAutoLineSlugs are stable ids for auto-dialogue (client localizes hero_meet.auto.).
+var HeroMeetAutoLineSlugs = []string{
+ "nod_traveler",
+ "quiet_road_today",
+ "heard_rumors_beasts",
+ "gear_clinks_soft",
+ "stay_safe_out_there",
+ "short_rest_then_go",
+ "sun_in_eyes",
+ "paths_cross_again",
+ "trade_news_smile",
+ "wind_picks_up",
+ "good_luck_hunt",
+ "watch_the_brush",
+}
+
+// HeroMeetAutoPhraseKey returns phrase key e.g. hero_meet.auto.nod_traveler for log / WS.
+func HeroMeetAutoPhraseKey(lineIdx int) string {
+ if len(HeroMeetAutoLineSlugs) == 0 {
+ return "hero_meet.auto.fallback"
+ }
+ if lineIdx < 0 {
+ lineIdx = 0
+ }
+ slug := HeroMeetAutoLineSlugs[lineIdx%len(HeroMeetAutoLineSlugs)]
+ return "hero_meet.auto." + slug
+}
diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go
index d94294a..56e6929 100644
--- a/backend/internal/model/ws_message.go
+++ b/backend/internal/model/ws_message.go
@@ -283,3 +283,41 @@ type ExcursionPhasePayload struct {
// ExcursionEndPayload is sent when the mini-adventure completes.
type ExcursionEndPayload struct{}
+
+// HeroMeetPartnerSnapshot is the other hero for UI (render + name).
+type HeroMeetPartnerSnapshot struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Level int `json:"level"`
+ PositionX float64 `json:"positionX"`
+ PositionY float64 `json:"positionY"`
+}
+
+// HeroMeetStartPayload begins a paired meet session (server → client).
+// MeetPhase is "out" while walking to the stand, "meet" when dialogue is active (may be sent twice).
+type HeroMeetStartPayload struct {
+ Partner HeroMeetPartnerSnapshot `json:"partner"`
+ AnySideOnline bool `json:"anySideOnline"`
+ PromptEndsAt *time.Time `json:"promptEndsAt,omitempty"`
+ PartnerLingerMs int64 `json:"partnerLingerMs,omitempty"`
+ MeetPhase string `json:"meetPhase,omitempty"` // "out" | "meet"
+}
+
+// HeroMeetLinePayload is one spoken line (player or scripted auto).
+type HeroMeetLinePayload struct {
+ FromHeroID int64 `json:"fromHeroId"`
+ Kind string `json:"kind"` // "player" | "scripted"
+ Text string `json:"text,omitempty"`
+ LineKey string `json:"lineKey,omitempty"`
+}
+
+// HeroMeetEndPayload ends the meet; client resumes walking and may linger partner visually.
+type HeroMeetEndPayload struct {
+ Reason string `json:"reason"` // offline_timer | user_end | admin | partner_gone
+ PartnerLingerMs int64 `json:"partnerLingerMs,omitempty"`
+}
+
+// HeroMeetSendMessagePayload is the client → server chat line (max 140 runes).
+type HeroMeetSendMessagePayload struct {
+ Text string `json:"text"`
+}
diff --git a/backend/internal/profanity/heroname.go b/backend/internal/profanity/heroname.go
index fd855a8..065057d 100644
--- a/backend/internal/profanity/heroname.go
+++ b/backend/internal/profanity/heroname.go
@@ -70,3 +70,23 @@ func HeroNameIsProfane(name string) bool {
}
return false
}
+
+// ChatMessageIsProfane checks free-text chat lines (shorter than hero names may allow).
+func ChatMessageIsProfane(s string) bool {
+ if s == "" {
+ return false
+ }
+ folded := foldYoToE(s)
+ 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
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 9c6b5fe..9bbfc00 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -99,6 +99,7 @@ func New(deps Deps) *chi.Mux {
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}/start-hero-meet", adminH.StartHeroMeet)
r.Post("/heroes/{heroId}/trigger-random-encounter", adminH.TriggerRandomEncounter)
r.Post("/heroes/{heroId}/kill-current-enemy", adminH.KillCurrentEnemy)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go
index e85f7c5..c5d7c3c 100644
--- a/backend/internal/tuning/runtime.go
+++ b/backend/internal/tuning/runtime.go
@@ -268,6 +268,23 @@ type Values struct {
// RoadsideRestDepthWorldUnits is the perpendicular offset from road during roadside rest.
RoadsideRestDepthWorldUnits float64 `json:"roadsideRestDepthWorldUnits"`
+
+ // --- Hero meet (paired social encounter) ---
+ // HeroMeetStandHalfOffsetWorld is half the lateral gap between the two heroes at the meet stand (world X from midpoint).
+ // Large values push the partner off-screen: isometric projection scales ~1 world unit to tens of pixels.
+ HeroMeetStandHalfOffsetWorld float64 `json:"heroMeetStandHalfOffsetWorld"`
+ // HeroMeetAdminSnapSeparationWorld: admin-started meet teleports the other hero to this distance from the primary (world units).
+ HeroMeetAdminSnapSeparationWorld float64 `json:"heroMeetAdminSnapSeparationWorld"`
+ HeroMeetRadiusWorld float64 `json:"heroMeetRadiusWorld"`
+ HeroMeetChancePerTick float64 `json:"heroMeetChancePerTick"`
+ HeroMeetCooldownMs int64 `json:"heroMeetCooldownMs"`
+ HeroMeetOfflineMinMs int64 `json:"heroMeetOfflineMinMs"`
+ HeroMeetOfflineMaxMs int64 `json:"heroMeetOfflineMaxMs"`
+ HeroMeetPromptWindowMs int64 `json:"heroMeetPromptWindowMs"`
+ HeroMeetAutoLineIntervalMs int64 `json:"heroMeetAutoLineIntervalMs"`
+ HeroMeetPartnerLingerMs int64 `json:"heroMeetPartnerLingerMs"`
+ HeroMeetMessageMaxRunes int `json:"heroMeetMessageMaxRunes"`
+ HeroMeetMessageCooldownMs int64 `json:"heroMeetMessageCooldownMs"`
}
func DefaultValues() Values {
@@ -460,6 +477,19 @@ func DefaultValues() Values {
AdventureRestHpPerS: 0.004,
RoadsideRestDepthWorldUnits: 12.0,
+
+ HeroMeetStandHalfOffsetWorld: 0.9,
+ HeroMeetAdminSnapSeparationWorld: 12.0,
+ HeroMeetRadiusWorld: 120.0,
+ HeroMeetChancePerTick: 0.0004,
+ HeroMeetCooldownMs: 300_000,
+ HeroMeetOfflineMinMs: 4 * 60 * 1000,
+ HeroMeetOfflineMaxMs: 6 * 60 * 1000,
+ HeroMeetPromptWindowMs: 20_000,
+ HeroMeetAutoLineIntervalMs: 10_000,
+ HeroMeetPartnerLingerMs: 20_000,
+ HeroMeetMessageMaxRunes: 140,
+ HeroMeetMessageCooldownMs: 3000,
}
}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c169e41..0dc4167 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"pixi.js": "^8.6.6",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "yaml": "^2.7.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
@@ -2972,6 +2973,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0dd19b7..a548291 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,15 @@
import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { GameEngine } from './game/engine';
-import { GamePhase, BuffType, type GameState, type ActiveBuff, type NPCData, type AttackPayload, type EnemyRegenPayload } from './game/types';
+import {
+ GamePhase,
+ BuffType,
+ type GameState,
+ type ActiveBuff,
+ type NPCData,
+ type AttackPayload,
+ type EnemyRegenPayload,
+ type NearbyHeroData,
+} from './game/types';
import type { NPCEncounterEvent } from './game/types';
import { GameWebSocket } from './network/websocket';
import {
@@ -86,8 +95,9 @@ import { AchievementsPanel } from './ui/AchievementsPanel';
import { Minimap } from './ui/Minimap';
import { NPCInteraction } from './ui/NPCInteraction';
import { WanderingNPCPopup } from './ui/WanderingNPCPopup';
+import { HeroMeetPanel } from './ui/HeroMeetPanel';
import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n';
-import { randomRoadsideThoughtLine } from './i18n/loadLocales';
+import { adventureLogTemplate, randomRoadsideThoughtLine } from './i18n/loadLocales';
const appStyle: CSSProperties = {
width: '100%',
@@ -298,7 +308,8 @@ function heroResponseToState(res: HeroResponse): HeroState {
excursionKind:
res.excursionKind === 'roadside' ||
res.excursionKind === 'adventure' ||
- res.excursionKind === 'town'
+ res.excursionKind === 'town' ||
+ res.excursionKind === 'hero_meet'
? (res.excursionKind as HeroState['excursionKind'])
: undefined,
townTourPhase: res.townTourPhase,
@@ -435,6 +446,12 @@ export function App() {
// Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState(null);
+
+ const [heroMeetPanel, setHeroMeetPanel] = useState<{
+ partnerName: string;
+ anySideOnline: boolean;
+ maxChars: number;
+ } | null>(null);
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle);
// Achievements
const [achievements, setAchievements] = useState([]);
@@ -1062,6 +1079,44 @@ export function App() {
hapticNotification('success');
refreshEquipment();
},
+
+ onHeroMeetStart: (p) => {
+ const partner: NearbyHeroData = {
+ id: p.partner.id,
+ name: p.partner.name,
+ level: p.partner.level,
+ positionX: p.partner.positionX,
+ positionY: p.partner.positionY,
+ };
+ engine.setHeroMeetOverlay(partner);
+ const phase = p.meetPhase;
+ const showChat = phase === 'meet' || phase === undefined;
+ if (showChat) {
+ setHeroMeetPanel({
+ partnerName: p.partner.name,
+ anySideOnline: p.anySideOnline,
+ maxChars: 140,
+ });
+ } else {
+ setHeroMeetPanel(null);
+ }
+ },
+
+ onHeroMeetLine: (p) => {
+ const loc = i18nForLogRef.current.locale;
+ let text = (p.text ?? '').trim();
+ if (p.kind === 'scripted' && p.lineKey) {
+ text = (adventureLogTemplate(loc, p.lineKey) ?? p.lineKey).trim();
+ }
+ if (!text) return;
+ engine.applyHeroMeetChatLine(p.fromHeroId, p.kind, text);
+ },
+
+ onHeroMeetEnd: (p) => {
+ setHeroMeetPanel(null);
+ const linger = p.partnerLingerMs != null && p.partnerLingerMs > 0 ? p.partnerLingerMs : 20000;
+ engine.endHeroMeetOverlayLinger(linger);
+ },
});
// ---- Telegram Theme Listener ----
@@ -1454,6 +1509,15 @@ export function App() {
}}
/>
+ {heroMeetPanel && wsRef.current ? (
+
+ ) : null}
+
{gameState.hero && (
> = {};
@@ -175,6 +192,80 @@ export class GameEngine {
this._nearbyHeroes = heroes;
}
+ /** Start hero meet UI overlay (partner stands near you). */
+ setHeroMeetOverlay(partner: NearbyHeroData): void {
+ this._heroMeetOverlay = { ...partner };
+ this._heroMeetLingerEndMs = 0;
+ this._meetBubbleSelf = null;
+ this._meetBubbleOther = null;
+ }
+
+ /** End meet but keep partner visible for lingerMs (perf ms). */
+ endHeroMeetOverlayLinger(lingerMs: number): void {
+ const now = performance.now();
+ this._heroMeetLingerEndMs = now + Math.max(0, lingerMs);
+ }
+
+ clearHeroMeetOverlay(): void {
+ this._heroMeetOverlay = null;
+ this._heroMeetLingerEndMs = 0;
+ this._meetBubbleSelf = null;
+ this._meetBubbleOther = null;
+ }
+
+ applyHeroMeetChatLine(
+ fromHeroId: number,
+ kind: 'player' | 'scripted',
+ displayText: string,
+ ): void {
+ const myId = this._gameState.hero?.id;
+ if (!myId || !displayText.trim()) return;
+ const entry = {
+ text: displayText,
+ startMs: performance.now(),
+ player: kind === 'player',
+ ownPlayerMessage: kind === 'player' && fromHeroId === myId,
+ };
+ if (fromHeroId === myId) {
+ this._meetBubbleSelf = entry;
+ } else {
+ this._meetBubbleOther = entry;
+ }
+ }
+
+ private _mergedNearbyHeroes(now: number): NearbyHeroData[] {
+ const base = [...this._nearbyHeroes];
+ if (this._heroMeetLingerEndMs > 0 && now > this._heroMeetLingerEndMs) {
+ this._heroMeetOverlay = null;
+ this._heroMeetLingerEndMs = 0;
+ }
+ const o = this._heroMeetOverlay;
+ if (!o) return base;
+ // Meet / linger: partner is drawn as a second full hero sprite (drawMeetPartner), not a nearby diamond.
+ return [];
+ }
+
+ private _heroMeetBubbleTTLms = 9000;
+
+ /** True only during hero_meet dialogue (server phase "meet"), not approach/return walks. */
+ private _heroMeetDialoguePhase(): boolean {
+ const h = this._gameState.hero;
+ return (
+ h?.excursionKind === 'hero_meet' &&
+ h.excursionPhase?.toLowerCase() === 'meet'
+ );
+ }
+
+ private _pruneMeetBubbles(now: number): void {
+ const ttl = this._heroMeetBubbleTTLms;
+ if (this._meetBubbleSelf && now - this._meetBubbleSelf.startMs > ttl) {
+ this._meetBubbleSelf = null;
+ }
+ if (this._meetBubbleOther && now - this._meetBubbleOther.startMs > ttl) {
+ this._meetBubbleOther = null;
+ }
+ }
+
// ---- Server State Application ----
/**
@@ -235,6 +326,24 @@ export class GameEngine {
targetY: number,
speed: number,
): void {
+ if (this._heroMeetDialoguePhase()) {
+ this._heroDisplayX = x;
+ this._heroDisplayY = y;
+ this._prevPositionX = x;
+ this._prevPositionY = y;
+ this._targetPositionX = x;
+ this._targetPositionY = y;
+ this._moveTargetX = targetX;
+ this._moveTargetY = targetY;
+ this._heroSpeed = speed;
+ this._lastMoveUpdateTime = performance.now();
+ if (this._gameState.hero) {
+ this._gameState.hero.position.x = x;
+ this._gameState.hero.position.y = y;
+ }
+ return;
+ }
+
this._prevPositionX = this._heroDisplayX;
this._prevPositionY = this._heroDisplayY;
this._targetPositionX = x;
@@ -405,6 +514,22 @@ export class GameEngine {
// Display position: follow hero_move interpolation and applyPositionSync only.
// Do not snap here on hero_state vs last move — server position includes
// excursion/rest offsets and ordering with hero_move caused visible teleports.
+ // Exception: active hero_meet stand is fixed; snap so we don't rely on hero_move ticks.
+ if (
+ hero.excursionKind === 'hero_meet' &&
+ hero.excursionPhase?.toLowerCase() === 'meet' &&
+ hero.position
+ ) {
+ const x = hero.position.x;
+ const y = hero.position.y;
+ this._heroDisplayX = x;
+ this._heroDisplayY = y;
+ this._prevPositionX = x;
+ this._prevPositionY = y;
+ this._targetPositionX = x;
+ this._targetPositionY = y;
+ this._lastMoveUpdateTime = performance.now();
+ }
this._notifyStateChange();
}
@@ -783,6 +908,13 @@ export class GameEngine {
* position over that interval.
*/
private _interpolatePosition(): void {
+ if (this._heroMeetDialoguePhase()) {
+ this._heroDisplayX = this._targetPositionX;
+ this._heroDisplayY = this._targetPositionY;
+ this._prevPositionX = this._targetPositionX;
+ this._prevPositionY = this._targetPositionY;
+ return;
+ }
const elapsed =
(performance.now() - this._lastMoveUpdateTime) / 1000;
const t = Math.min(elapsed / MOVE_UPDATE_INTERVAL_S, 1.0);
@@ -828,11 +960,17 @@ export class GameEngine {
if (state.hero) {
const isWalking = state.phase === GamePhase.Walking;
const isFighting = state.phase === GamePhase.Fighting;
- const animPhase = isWalking
- ? 'walk'
- : isFighting
- ? 'fight'
- : 'idle';
+ const inHeroMeetDialogue =
+ state.hero.excursionKind === 'hero_meet' &&
+ state.hero.excursionPhase?.toLowerCase() === 'meet';
+ const animPhase =
+ inHeroMeetDialogue && isWalking
+ ? 'idle'
+ : isWalking
+ ? 'walk'
+ : isFighting
+ ? 'fight'
+ : 'idle';
this.renderer.drawHero(
this._heroDisplayX,
@@ -883,13 +1021,53 @@ export class GameEngine {
this.renderer.clearNPCs();
}
- // Draw nearby heroes from the shared world
- if (this._nearbyHeroes.length > 0) {
- this.renderer.drawNearbyHeroes(this._nearbyHeroes, now);
+ // Draw nearby heroes from the shared world (meet partner uses full sprite below, not diamonds)
+ const nearbyMerged = this._mergedNearbyHeroes(now);
+ if (nearbyMerged.length > 0) {
+ this.renderer.drawNearbyHeroes(nearbyMerged, now);
} else {
this.renderer.clearNearbyHeroes();
}
+ const meetOv = this._heroMeetOverlay;
+ if (meetOv) {
+ this.renderer.drawMeetPartner(meetOv.positionX, meetOv.positionY, meetOv.name, meetOv.level, now);
+ } else {
+ this.renderer.clearMeetPartner();
+ }
+
+ this._pruneMeetBubbles(now);
+ const bubbleItems: Array<{
+ wx: number;
+ wy: number;
+ text: string;
+ startMs: number;
+ ownPlayerMessage: boolean;
+ }> = [];
+ if (this._meetBubbleSelf) {
+ bubbleItems.push({
+ wx: this._heroDisplayX,
+ wy: this._heroDisplayY,
+ text: this._meetBubbleSelf.text,
+ startMs: this._meetBubbleSelf.startMs,
+ ownPlayerMessage: this._meetBubbleSelf.ownPlayerMessage,
+ });
+ }
+ if (this._meetBubbleOther && meetOv) {
+ bubbleItems.push({
+ wx: meetOv.positionX,
+ wy: meetOv.positionY,
+ text: this._meetBubbleOther.text,
+ startMs: this._meetBubbleOther.startMs,
+ ownPlayerMessage: this._meetBubbleOther.ownPlayerMessage,
+ });
+ }
+ if (bubbleItems.length > 0) {
+ this.renderer.drawHeroMeetBubbles(bubbleItems, now);
+ } else {
+ this.renderer.clearHeroMeetBubbles();
+ }
+
// Draw enemy during combat or death
const showEnemy =
state.enemy &&
diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts
index 61e8223..9e46a18 100644
--- a/frontend/src/game/renderer.ts
+++ b/frontend/src/game/renderer.ts
@@ -76,11 +76,18 @@ export class GameRenderer {
// Reusable Graphics objects (avoid GC in hot path)
private _groundGfx: Graphics | null = null;
private _heroGfx: Graphics | null = null;
+ /** Second adventurer silhouette during hero_meet (opponent at server stand position). */
+ private _meetPartnerGfx: Graphics | null = null;
+ private _meetPartnerLabel: Text | null = null;
/** Tent + campfire while resting in the wild phase (roadside / adventure inline). */
private _restCampGfx: Graphics | null = null;
private _enemyGfx: Graphics | null = null;
private _thoughtGfx: Graphics | null = null;
private _thoughtText: Text | null = null;
+ private _meetBubbleSlots: Array<{
+ gfx: Graphics;
+ txt: Text;
+ }> = [];
private _heroNameText: Text | null = null;
private _heroName = '';
@@ -317,6 +324,9 @@ export class GameRenderer {
this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx);
+ this._meetPartnerGfx = new Graphics();
+ this.entityLayer.addChild(this._meetPartnerGfx);
+
this._restCampGfx = new Graphics();
this.entityLayer.addChild(this._restCampGfx);
@@ -341,6 +351,26 @@ export class GameRenderer {
this._thoughtText.visible = false;
this.entityLayer.addChild(this._thoughtText);
+ for (let i = 0; i < 2; i++) {
+ const gfx = new Graphics();
+ this.entityLayer.addChild(gfx);
+ const txt = new Text({
+ text: '',
+ style: new TextStyle({
+ fontSize: 10,
+ fontFamily: 'system-ui, sans-serif',
+ fill: 0x333333,
+ wordWrap: true,
+ wordWrapWidth: 200,
+ align: 'center',
+ }),
+ });
+ txt.anchor.set(0.5, 0.5);
+ txt.visible = false;
+ this.entityLayer.addChild(txt);
+ this._meetBubbleSlots.push({ gfx, txt });
+ }
+
this._heroNameText = new Text({
text: '',
style: new TextStyle({
@@ -355,6 +385,20 @@ export class GameRenderer {
this._heroNameText.visible = false;
this.entityLayer.addChild(this._heroNameText);
+ this._meetPartnerLabel = new Text({
+ text: '',
+ style: new TextStyle({
+ fontSize: 11,
+ fontFamily: 'system-ui, sans-serif',
+ fill: 0xffffff,
+ stroke: { color: 0x000000, width: 3 },
+ align: 'center',
+ }),
+ });
+ this._meetPartnerLabel.anchor.set(0.5, 0.5);
+ this._meetPartnerLabel.visible = false;
+ this.entityLayer.addChild(this._meetPartnerLabel);
+
// Town graphics (drawn between ground and entity layers)
this._townGfx = new Graphics();
this.groundLayer.addChild(this._townGfx);
@@ -491,48 +535,50 @@ export class GameRenderer {
}
/**
- * Draw the hero as a compact adventurer (silhouette + cape + blade) with bob / combat flash.
+ * Shared adventurer figure for local hero and meet opponent (tint differs for readability).
*/
- drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number): void {
- const gfx = this._heroGfx;
- if (!gfx) return;
+ private paintHeroSilhouette(
+ gfx: Graphics,
+ wx: number,
+ wy: number,
+ phase: 'walk' | 'fight' | 'idle',
+ now: number,
+ variant: 'self' | 'meet_partner',
+ ): { cx: number; cy: number; iso: ScreenPoint } {
gfx.clear();
-
const iso = worldToScreen(wx, wy);
let yOffset = 0;
-
if (phase === 'walk') {
yOffset = Math.sin(now * 0.006) * 3;
} else if (phase === 'fight') {
yOffset = Math.sin(now * 0.012) * 2;
}
-
const cx = iso.x;
const cy = iso.y + yOffset;
- // Shadow
+ const cape = variant === 'meet_partner' ? 0x523068 : 0x6a1a2e;
+ const tunic = variant === 'meet_partner' ? 0x2e5070 : 0x3a5a8a;
+ const tunicStroke = variant === 'meet_partner' ? 0x1a2840 : 0x1a3050;
+ const helm = variant === 'meet_partner' ? 0x7a8fa0 : 0x8899aa;
+ const helmStroke = variant === 'meet_partner' ? 0x4a5a68 : 0x556070;
+
gfx.ellipse(cx, cy + 10, 16, 5);
gfx.fill({ color: 0x000000, alpha: 0.28 });
- // Cape (behind body)
gfx.poly([cx - 14, cy - 4, cx - 18, cy + 10, cx + 2, cy + 6, cx + 4, cy - 8]);
- gfx.fill({ color: 0x6a1a2e, alpha: 0.92 });
+ gfx.fill({ color: cape, alpha: 0.92 });
- // Torso / tunic
gfx.roundRect(cx - 9, cy - 18, 18, 22, 4);
- gfx.fill({ color: 0x3a5a8a, alpha: 0.98 });
- gfx.stroke({ color: 0x1a3050, width: 1.2 });
+ gfx.fill({ color: tunic, alpha: 0.98 });
+ gfx.stroke({ color: tunicStroke, width: 1.2 });
- // Belt
gfx.rect(cx - 9, cy + 2, 18, 4);
gfx.fill({ color: 0x3a2818, alpha: 0.95 });
- // Head / helm
gfx.roundRect(cx - 7, cy - 30, 14, 13, 5);
- gfx.fill({ color: 0x8899aa, alpha: 0.98 });
- gfx.stroke({ color: 0x556070, width: 1 });
+ gfx.fill({ color: helm, alpha: 0.98 });
+ gfx.stroke({ color: helmStroke, width: 1 });
- // Blade (readability at small zoom)
gfx.rect(cx + 8, cy - 22, 3, 20);
gfx.fill({ color: 0xc8d8e8, alpha: 0.95 });
gfx.rect(cx + 8, cy - 4, 6, 3);
@@ -547,19 +593,49 @@ export class GameRenderer {
}
gfx.zIndex = cy + 100;
+ return { cx, cy, iso };
+ }
+
+ /**
+ * Draw the hero as a compact adventurer (silhouette + cape + blade) with bob / combat flash.
+ */
+ drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number): void {
+ const gfx = this._heroGfx;
+ if (!gfx) return;
+ const { cy, iso } = this.paintHeroSilhouette(gfx, wx, wy, phase, now, 'self');
- // Hero name label above head (or above thought bubble if visible)
const nameTxt = this._heroNameText;
if (nameTxt && this._heroName) {
nameTxt.text = this._heroName;
nameTxt.x = iso.x;
- // Default: above hero head; drawThoughtBubble will reposition if active
nameTxt.y = iso.y - 42;
nameTxt.visible = true;
nameTxt.zIndex = cy + 199;
}
}
+ /**
+ * Meet opponent: same figure as the main hero, idle stance, distinct tint + name label.
+ */
+ drawMeetPartner(wx: number, wy: number, name: string, level: number, now: number): void {
+ const gfx = this._meetPartnerGfx;
+ const lbl = this._meetPartnerLabel;
+ if (!gfx || !lbl) return;
+ const { cy, iso } = this.paintHeroSilhouette(gfx, wx, wy, 'idle', now, 'meet_partner');
+ lbl.text = `${name} Lv.${level}`;
+ lbl.x = iso.x;
+ lbl.y = iso.y - 42;
+ lbl.visible = true;
+ lbl.zIndex = cy + 199;
+ }
+
+ clearMeetPartner(): void {
+ this._meetPartnerGfx?.clear();
+ if (this._meetPartnerLabel) {
+ this._meetPartnerLabel.visible = false;
+ }
+ }
+
/**
* Draw a small camp (A-frame tent + campfire) near the hero during wilderness rest (wild phase).
* Placed slightly behind the hero in screen space for a “bivouac” read.
@@ -1422,6 +1498,72 @@ export class GameRenderer {
}
}
+ /**
+ * Up to two speech bubbles for hero meet. Local player's typed line: tinted fill; all lines use dark text.
+ */
+ drawHeroMeetBubbles(
+ items: ReadonlyArray<{
+ wx: number;
+ wy: number;
+ text: string;
+ startMs: number;
+ ownPlayerMessage: boolean;
+ }>,
+ now: number,
+ ): void {
+ for (let i = 0; i < this._meetBubbleSlots.length; i++) {
+ const slot = this._meetBubbleSlots[i]!;
+ const item = items[i];
+ const gfx = slot.gfx;
+ const txt = slot.txt;
+ if (!item || !item.text.trim()) {
+ gfx.clear();
+ txt.visible = false;
+ continue;
+ }
+ const elapsed = now - item.startMs;
+ const fadeIn = Math.min(1, elapsed / 200);
+ const alpha = fadeIn;
+ if (alpha <= 0) {
+ txt.visible = false;
+ gfx.clear();
+ continue;
+ }
+ const iso = worldToScreen(item.wx, item.wy);
+ const bx = iso.x;
+ const by = iso.y - 48;
+ txt.text = item.text;
+ txt.style.fill = 0x1a1a1a;
+ txt.style.stroke = { color: 0x000000, width: 0 };
+ const padX = 8;
+ const padY = 6;
+ const w = Math.min(220, Math.max(72, Math.ceil(txt.width) + padX * 2));
+ const h = Math.max(26, Math.ceil(txt.height) + padY * 2);
+ const left = bx - w / 2;
+ const top = by - h / 2;
+ const fillRgb = item.ownPlayerMessage ? 0xcffafe : 0xffffff;
+ const strokeRgb = item.ownPlayerMessage ? 0x67e8f9 : 0xcccccc;
+ gfx.clear();
+ gfx.roundRect(left, top, w, h, 6);
+ gfx.fill({ color: fillRgb, alpha: 0.94 * alpha });
+ gfx.stroke({ color: strokeRgb, width: 1, alpha: 0.8 * alpha });
+ const triW = 7;
+ const triH = 5;
+ gfx.poly([bx - triW / 2, top + h, bx + triW / 2, top + h, bx, top + h + triH]);
+ gfx.fill({ color: fillRgb, alpha: 0.94 * alpha });
+ txt.x = bx;
+ txt.y = by;
+ txt.alpha = alpha;
+ txt.visible = true;
+ txt.zIndex = by + 250;
+ gfx.zIndex = by + 249;
+ }
+ }
+
+ clearHeroMeetBubbles(): void {
+ this.drawHeroMeetBubbles([], performance.now());
+ }
+
/** Clear nearby hero visuals when there are none to render */
clearNearbyHeroes(): void {
if (this._nearbyHeroGfx) this._nearbyHeroGfx.clear();
diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts
index c39d758..e8c101c 100644
--- a/frontend/src/game/types.ts
+++ b/frontend/src/game/types.ts
@@ -138,8 +138,8 @@ 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 vs in-town tour */
- excursionKind?: 'roadside' | 'adventure' | 'town';
+ /** Attractor excursion mode from server: roadside rest vs walking adventure vs in-town tour vs paired meet */
+ excursionKind?: 'roadside' | 'adventure' | 'town' | 'hero_meet';
/** Sub-phase during `excursionKind: town` (server-driven NPC UI). */
townTourPhase?: string;
townTourNpcId?: number;
@@ -469,6 +469,9 @@ export type ServerMessageType =
| 'town_tour_service_end'
| 'npc_encounter'
| 'npc_encounter_end'
+ | 'hero_meet_start'
+ | 'hero_meet_line'
+ | 'hero_meet_end'
| 'level_up'
| 'equipment_change'
| 'potion_collected'
@@ -650,6 +653,35 @@ export interface NPCEncounterEndPayload {
reason: 'timeout' | 'declined' | string;
}
+export interface HeroMeetPartnerSnapshot {
+ id: number;
+ name: string;
+ level: number;
+ positionX: number;
+ positionY: number;
+}
+
+export interface HeroMeetStartPayload {
+ partner: HeroMeetPartnerSnapshot;
+ anySideOnline: boolean;
+ promptEndsAt?: string;
+ partnerLingerMs?: number;
+ /** "out" = walking to stand (show partner on map only); "meet" = dialogue (open chat panel). */
+ meetPhase?: 'out' | 'meet' | 'return';
+}
+
+export interface HeroMeetLinePayload {
+ fromHeroId: number;
+ kind: 'player' | 'scripted';
+ text?: string;
+ lineKey?: string;
+}
+
+export interface HeroMeetEndPayload {
+ reason: string;
+ partnerLingerMs?: number;
+}
+
export interface LevelUpPayload {
newLevel: number;
statChanges: {
diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts
index 8040302..0ac9568 100644
--- a/frontend/src/game/ws-handler.ts
+++ b/frontend/src/game/ws-handler.ts
@@ -30,6 +30,9 @@ import type {
LootBonusItemSlot,
MerchantLootPayload,
DebuffAppliedPayload,
+ HeroMeetStartPayload,
+ HeroMeetLinePayload,
+ HeroMeetEndPayload,
} from './types';
import { DebuffType, Rarity } from './types';
// ---- Callback types for UI layer (App.tsx) ----
@@ -60,6 +63,9 @@ export interface WSHandlerCallbacks {
onError?: (payload: ServerErrorPayload) => void;
onHeroStateReceived?: (hero: Record) => void;
onMerchantLoot?: (payload: MerchantLootPayload) => void;
+ onHeroMeetStart?: (payload: HeroMeetStartPayload) => void;
+ onHeroMeetLine?: (payload: HeroMeetLinePayload) => void;
+ onHeroMeetEnd?: (payload: HeroMeetEndPayload) => void;
}
/**
@@ -272,6 +278,23 @@ export function wireWSHandler(
console.warn('[WS] Server error:', p.code, p.message);
callbacks.onError?.(p);
});
+
+ // ---- Server -> Client: Hero meet ----
+
+ ws.on('hero_meet_start', (msg: ServerMessage) => {
+ const p = msg.payload as HeroMeetStartPayload;
+ callbacks.onHeroMeetStart?.(p);
+ });
+
+ ws.on('hero_meet_line', (msg: ServerMessage) => {
+ const p = msg.payload as HeroMeetLinePayload;
+ callbacks.onHeroMeetLine?.(p);
+ });
+
+ ws.on('hero_meet_end', (msg: ServerMessage) => {
+ const p = msg.payload as HeroMeetEndPayload;
+ callbacks.onHeroMeetEnd?.(p);
+ });
}
// ---- Client -> Server command helpers ----
@@ -320,6 +343,14 @@ export function sendTownTourNPCInteractionClosed(ws: GameWebSocket): void {
ws.send('town_tour_npc_interaction_closed', {});
}
+export function sendHeroMeetMessage(ws: GameWebSocket, text: string): void {
+ ws.send('hero_meet_send_message', { text });
+}
+
+export function sendHeroMeetEndConversation(ws: GameWebSocket): void {
+ ws.send('hero_meet_end_conversation', {});
+}
+
/**
* Build a LootDrop from combat_end payload for the loot popup UI.
*/
diff --git a/frontend/src/i18n/en.yml b/frontend/src/i18n/en.yml
index 9659b67..738603e 100644
--- a/frontend/src/i18n/en.yml
+++ b/frontend/src/i18n/en.yml
@@ -111,6 +111,11 @@ ui:
healToFullForGold: Heal to Full ({cost}g)
viewQuests: View Quests
npcInteractTalk: Talk
+ heroMeetTitle: Meeting
+ heroMeetPlaceholder: Say something… (max {max} characters)
+ heroMeetSend: Send
+ heroMeetEndConversation: End conversation
+ heroMeetCharCount: '{current}/{max}'
shopHealingPotionName: Healing Potion
shopHealingPotionDesc: Restores health. Always handy in a pinch.
shopFullHealName: Full Heal
@@ -221,6 +226,19 @@ adventure_log:
log.combat.hero_stun: You are stunned and cannot attack.{debuffPart}
log.combat.enemy_hit: '{enemy} hits you for {damage} damage{crit}{debuffPart}'
log.combat.enemy_block: "You block {enemy}'s attack.{debuffPart}"
+ log.hero_meet.player_said: '{speaker}: {text}'
+ hero_meet.auto.nod_traveler: '*nods* Long road today.'
+ hero_meet.auto.quiet_road_today: Quiet stretch of road here.
+ hero_meet.auto.heard_rumors_beasts: Heard there are beasts ahead — stay sharp.
+ hero_meet.auto.gear_clinks_soft: Your gear looks well kept.
+ hero_meet.auto.stay_safe_out_there: Travel safe.
+ hero_meet.auto.short_rest_then_go: I'll rest a moment, then push on.
+ hero_meet.auto.sun_in_eyes: Sun's in my eyes — good day for walking, though.
+ hero_meet.auto.paths_cross_again: Maybe our paths cross again.
+ hero_meet.auto.trade_news_smile: Any news from the last town?
+ hero_meet.auto.wind_picks_up: Wind's picking up.
+ hero_meet.auto.good_luck_hunt: Good luck out there.
+ hero_meet.auto.watch_the_brush: Watch the treeline.
achievements:
first_blood: First Blood
diff --git a/frontend/src/i18n/ru.yml b/frontend/src/i18n/ru.yml
index 53243d3..d25febf 100644
--- a/frontend/src/i18n/ru.yml
+++ b/frontend/src/i18n/ru.yml
@@ -111,6 +111,11 @@ ui:
healToFullForGold: 'Полное лечение ({cost}з)'
viewQuests: 'Квесты'
npcInteractTalk: 'Поговорить'
+ heroMeetTitle: 'Встреча'
+ heroMeetPlaceholder: 'Напишите сообщение… (до {max} символов)'
+ heroMeetSend: 'Отправить'
+ heroMeetEndConversation: 'Завершить разговор'
+ heroMeetCharCount: '{current}/{max}'
shopHealingPotionName: 'Зелье лечения'
shopHealingPotionDesc: 'Восстанавливает здоровье. Всегда полезно.'
shopFullHealName: 'Полное лечение'
@@ -221,6 +226,19 @@ adventure_log:
log.combat.hero_stun: 'Вы оглушены и не можете атаковать.{debuffPart}'
log.combat.enemy_hit: '{enemy} бьёт вас на {damage} урона{crit}{debuffPart}'
log.combat.enemy_block: 'Вы блокируете атаку {enemy}.{debuffPart}'
+ log.hero_meet.player_said: '{speaker}: {text}'
+ hero_meet.auto.nod_traveler: '*кивает* Долгая дорога сегодня.'
+ hero_meet.auto.quiet_road_today: Тихий участок пути.
+ hero_meet.auto.heard_rumors_beasts: Слышал, впереди зверье — будь осторожен.
+ hero_meet.auto.gear_clinks_soft: Снаряжение у тебя в порядке.
+ hero_meet.auto.stay_safe_out_there: Удачи в пути.
+ hero_meet.auto.short_rest_then_go: Отдохну чуть-чуть и пойду дальше.
+ hero_meet.auto.sun_in_eyes: Слепит солнце — но день хорош для дороги.
+ hero_meet.auto.paths_cross_again: Может, ещё свидимся.
+ hero_meet.auto.trade_news_smile: Есть новости из прошлого города?
+ hero_meet.auto.wind_picks_up: Ветер усиливается.
+ hero_meet.auto.good_luck_hunt: Удачи.
+ hero_meet.auto.watch_the_brush: Смотри под кусты.
achievements:
first_blood: 'Первая кровь'
diff --git a/frontend/src/i18n/types.ts b/frontend/src/i18n/types.ts
index fb8fc34..9d1751d 100644
--- a/frontend/src/i18n/types.ts
+++ b/frontend/src/i18n/types.ts
@@ -111,6 +111,11 @@ export interface Translations {
healToFullForGold: string;
viewQuests: string;
npcInteractTalk: string;
+ heroMeetTitle: string;
+ heroMeetPlaceholder: string;
+ heroMeetSend: string;
+ heroMeetEndConversation: string;
+ heroMeetCharCount: string;
shopHealingPotionName: string;
shopHealingPotionDesc: string;
shopFullHealName: string;
diff --git a/frontend/src/shared/telegram.ts b/frontend/src/shared/telegram.ts
index 6dfeccb..d067255 100644
--- a/frontend/src/shared/telegram.ts
+++ b/frontend/src/shared/telegram.ts
@@ -112,8 +112,31 @@ export function getTelegramInitData(): string {
return getTelegramWebApp()?.initData ?? '';
}
-/** Get the Telegram user ID from initDataUnsafe, or null if unavailable */
+/** Same host rules as backend resolveTelegramID dev fallback (query telegramId). */
+function isDevTelegramIdQueryHost(): boolean {
+ if (typeof window === 'undefined') return false;
+ const h = window.location.hostname;
+ // Mirror backend resolveTelegramID host checks (hostname has no port).
+ return h === 'localhost' || h === '127.0.0.1' || h === '192.168.0.53';
+}
+
+/** Parse ?telegramId= for local/browser dev; ignored on production hosts. */
+function readDevTelegramIdFromQuery(): number | null {
+ if (!isDevTelegramIdQueryHost()) return null;
+ const raw = new URLSearchParams(window.location.search).get('telegramId');
+ if (raw == null || raw === '') return null;
+ const id = Number.parseInt(raw, 10);
+ if (!Number.isFinite(id) || id <= 0) return null;
+ return id;
+}
+
+/**
+ * Telegram user id for API/WS: dev query ?telegramId= on localhost/LAN, else initDataUnsafe.user.id.
+ */
export function getTelegramUserId(): number | null {
+ const fromQuery = readDevTelegramIdFromQuery();
+ if (fromQuery != null) return fromQuery;
+
const tg = getTelegramWebApp();
if (!tg) return null;
const user = tg.initDataUnsafe?.user as { id?: number } | undefined;
diff --git a/frontend/src/ui/HeroMeetPanel.tsx b/frontend/src/ui/HeroMeetPanel.tsx
new file mode 100644
index 0000000..75f952b
--- /dev/null
+++ b/frontend/src/ui/HeroMeetPanel.tsx
@@ -0,0 +1,141 @@
+import { useCallback, useState, type CSSProperties } from 'react';
+import type { GameWebSocket } from '../network/websocket';
+import { useT } from '../i18n';
+import { sendHeroMeetEndConversation, sendHeroMeetMessage } from '../game/ws-handler';
+
+const panelStyle: CSSProperties = {
+ position: 'absolute',
+ bottom: 130,
+ left: '50%',
+ transform: 'translateX(-50%)',
+ minWidth: 240,
+ maxWidth: 340,
+ backgroundColor: 'rgba(15, 15, 25, 0.94)',
+ border: '1px solid rgba(34, 211, 238, 0.35)',
+ borderRadius: 12,
+ padding: '10px 14px 12px',
+ zIndex: 125,
+ pointerEvents: 'auto',
+ boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
+};
+
+interface HeroMeetPanelProps {
+ partnerName: string;
+ anySideOnline: boolean;
+ ws: GameWebSocket | null;
+ maxChars?: number;
+}
+
+export function HeroMeetPanel({
+ partnerName,
+ anySideOnline,
+ ws,
+ maxChars = 140,
+}: HeroMeetPanelProps) {
+ const tr = useT();
+ const [text, setText] = useState('');
+
+ const send = useCallback(() => {
+ const t = text.trim();
+ if (!t || !ws) return;
+ sendHeroMeetMessage(ws, t);
+ setText('');
+ }, [text, ws]);
+
+ const endConv = useCallback(() => {
+ if (ws) sendHeroMeetEndConversation(ws);
+ }, [ws]);
+
+ const len = [...text].length;
+
+ return (
+