diff --git a/admin-web/index.html b/admin-web/index.html index 237597a..b20280f 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -187,6 +187,8 @@ contentEnemies: [], contentEnemyEditor: null, combatSimForm: { heroId: "", heroQuery: "", heroPickName: "", enemyType: "", enemyFilter: "", enemyLevel: "", delayMs: 0, maxEvents: 400 }, + /** Hero details: partner picker for POST …/start-hero-meet */ + heroMeetPartnerPick: { query: "", otherId: "", pickName: "", rows: [] }, combatSimHeroRows: [], combatSimResult: null, combatSimLive: null, @@ -844,6 +846,55 @@ state.combatSimForm.heroPickName = row ? String(row.name || "") : ""; render(); } + async function searchHeroesForHeroMeetPartner() { + const pick = state.heroMeetPartnerPick || (state.heroMeetPartnerPick = { query: "", otherId: "", pickName: "", rows: [] }); + const el = document.getElementById("hero-meet-partner-query"); + const q = (el && el.value != null ? el.value : pick.query || "").trim(); + pick.query = q; + const data = await api(`heroes?limit=50&offset=0&query=${encodeURIComponent(q)}`); + pick.rows = data.heroes || []; + render(); + } + async function loadRecentHeroesForHeroMeetPartner() { + const pick = state.heroMeetPartnerPick || (state.heroMeetPartnerPick = { query: "", otherId: "", pickName: "", rows: [] }); + pick.query = ""; + const data = await api(`heroes?limit=50&offset=0&query=`); + pick.rows = data.heroes || []; + render(); + } + function selectHeroMeetPartner(id) { + if (!state.selectedHeroId) return; + if (Number(id) === Number(state.selectedHeroId)) { + setMessage("Нельзя выбрать того же героя, что открыт в карточке."); + return; + } + const pick = state.heroMeetPartnerPick || (state.heroMeetPartnerPick = { query: "", otherId: "", pickName: "", rows: [] }); + const row = (pick.rows || []).find(h => Number(h.id) === Number(id)); + pick.otherId = String(id); + pick.pickName = row ? String(row.name || "") : ""; + render(); + } + async function startHeroMeetAdmin() { + const mainId = state.selectedHeroId; + const pick = state.heroMeetPartnerPick || {}; + const otherId = Number(pick.otherId || 0); + if (!mainId) return; + if (!otherId) { + setMessage("Выберите второго героя в списке (поиск по имени или id)."); + return; + } + if (Number(mainId) === otherId) { + setMessage("Нельзя начать встречу с самим собой: выберите другого героя."); + return; + } + await api(`heroes/${mainId}/start-hero-meet`, { + method: "POST", + body: JSON.stringify({ otherHeroId: otherId }), + }); + await loadHero(mainId); + startHeroMovementPoll(60); + setMessage(`Встреча героев: основной #${mainId}, партнёр #${otherId} (телепорт к основному)`); + } function applyCombatSimEnemyFilter() { const el = document.getElementById("combat-sim-enemy-filter"); if (el) state.combatSimForm.enemyFilter = el.value.trim(); @@ -1226,6 +1277,11 @@ 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 hmp = state.heroMeetPartnerPick; + if (hmp && hmp.otherId && Number(hmp.otherId) === Number(heroId)) { + hmp.otherId = ""; + hmp.pickName = ""; + } const newTid = hero.currentTownId; if (Number(state.townTourApproachNpcTownId) !== Number(newTid)) { state.townTourApproachNpcs = []; @@ -2092,6 +2148,19 @@ ID ${e(x.id)} `).join(""); + const hmPick = state.heroMeetPartnerPick || { query: "", otherId: "", pickName: "", rows: [] }; + const hmSelId = state.selectedHeroId; + const hmRowsHtml = (hmPick.rows || []).length + ? (hmPick.rows || []).map(x => { + const isSelf = hmSelId != null && Number(x.id) === Number(hmSelId); + const active = !isSelf && String(x.id) === String(hmPick.otherId) ? " active" : ""; + const click = isSelf ? "" : ` onclick="selectHeroMeetPartner(${x.id})"`; + const sty = isSelf ? "cursor:default;opacity:0.55" : "cursor:pointer"; + const tag = isSelf ? " (текущий)" : ""; + return `
${e(x.name || "(no name)")}Lvl ${e(x.level)}HP ${e(x.hp)}/${e(x.maxHp)}ID ${e(x.id)}${tag}
`; + }).join("") + : `
Введите имя или 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 ( +
+
+ {tr.heroMeetTitle}: {partnerName} +
+ {anySideOnline ? ( + <> +