hero meet

master
Denis Ranneft 1 month ago
parent 0c7468cc55
commit 532c4f4dfd

@ -187,6 +187,8 @@
contentEnemies: [], contentEnemies: [],
contentEnemyEditor: null, contentEnemyEditor: null,
combatSimForm: { heroId: "", heroQuery: "", heroPickName: "", enemyType: "", enemyFilter: "", enemyLevel: "", delayMs: 0, maxEvents: 400 }, 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: [], combatSimHeroRows: [],
combatSimResult: null, combatSimResult: null,
combatSimLive: null, combatSimLive: null,
@ -844,6 +846,55 @@
state.combatSimForm.heroPickName = row ? String(row.name || "") : ""; state.combatSimForm.heroPickName = row ? String(row.name || "") : "";
render(); 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() { function applyCombatSimEnemyFilter() {
const el = document.getElementById("combat-sim-enemy-filter"); const el = document.getElementById("combat-sim-enemy-filter");
if (el) state.combatSimForm.enemyFilter = el.value.trim(); if (el) state.combatSimForm.enemyFilter = el.value.trim();
@ -1226,6 +1277,11 @@
state.selectedHeroId = heroId; state.selectedHeroId = heroId;
const [hero, gear, quests] = await Promise.all([api(`heroes/${heroId}`), api(`heroes/${heroId}/gear`), api(`heroes/${heroId}/quests`)]); const [hero, gear, quests] = await Promise.all([api(`heroes/${heroId}`), api(`heroes/${heroId}/gear`), api(`heroes/${heroId}/quests`)]);
state.selectedHero = hero; state.gear = gear; state.quests = quests; state.selectedHero = hero; state.gear = gear; state.quests = quests;
const hmp = state.heroMeetPartnerPick;
if (hmp && hmp.otherId && Number(hmp.otherId) === Number(heroId)) {
hmp.otherId = "";
hmp.pickName = "";
}
const newTid = hero.currentTownId; const newTid = hero.currentTownId;
if (Number(state.townTourApproachNpcTownId) !== Number(newTid)) { if (Number(state.townTourApproachNpcTownId) !== Number(newTid)) {
state.townTourApproachNpcs = []; state.townTourApproachNpcs = [];
@ -2092,6 +2148,19 @@
<span>ID ${e(x.id)}</span> <span>ID ${e(x.id)}</span>
</div>`).join(""); </div>`).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 ? " <span class=\"muted\">(текущий)</span>" : "";
return `<div class="list-row${active}" style="${sty}"${click}><strong>${e(x.name || "(no name)")}</strong><span>Lvl ${e(x.level)}</span><span>HP ${e(x.hp)}/${e(x.maxHp)}</span><span>ID ${e(x.id)}${tag}</span></div>`;
}).join("")
: `<div class="list-row"><span class="muted">Введите имя или id и нажмите «Найти», либо «50 последних»</span><span></span><span></span><span></span></div>`;
let heroExtra = ""; let heroExtra = "";
let teleportOpts = `<option value="">— town —</option>`; let teleportOpts = `<option value="">— town —</option>`;
let townTourApproachPanel = ""; let townTourApproachPanel = "";
@ -2287,6 +2356,23 @@
<button type="button" class="btn" onclick="withAction(() => heroAction('trigger-random-encounter',{}))" title="Серверный бой со случайным монстром (как на дороге). Нужен подключённый клиент (WS), герой не в бою, не в городе и не в отдыхе">Встреча (случайный монстр)</button> <button type="button" class="btn" onclick="withAction(() => heroAction('trigger-random-encounter',{}))" title="Серверный бой со случайным монстром (как на дороге). Нужен подключённый клиент (WS), герой не в бою, не в городе и не в отдыхе">Встреча (случайный монстр)</button>
<button type="button" class="btn warn" onclick="withAction(() => heroAction('kill-current-enemy',{}))" title="Летальный удар от имени героя; награды и combat_end как при победе. Герой в бою, сессия в движке (WS)">Убить текущего монстра</button> <button type="button" class="btn warn" onclick="withAction(() => heroAction('kill-current-enemy',{}))" title="Летальный удар от имени героя; награды и combat_end как при победе. Герой в бою, сессия в движке (WS)">Убить текущего монстра</button>
</div> </div>
<div style="margin-top:12px;padding-top:10px;border-top:1px solid #2a3551">
<h4 style="margin:0 0 6px;font-size:13px;color:#cfe3ff">Встреча героев</h4>
<p class="muted" style="margin:0 0 8px;font-size:12px">Основной — открытый герой. Выберите второго в списке (поиск как на вкладке героев). Нельзя выбрать того же героя. <kbd>POST …/start-hero-meet</kbd> — партнёр в движке, телепорт к основному.</p>
<div class="row" style="flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:6px">
<div>
<label class="muted" style="font-size:11px">Поиск партнёра</label><br />
<input id="hero-meet-partner-query" value="${e(hmPick.query || "")}" oninput="state.heroMeetPartnerPick.query=this.value" placeholder="Имя или id" style="min-width:200px" />
</div>
<div style="align-self:end;display:flex;gap:6px;flex-wrap:wrap">
<button type="button" class="btn" onclick="withAction(searchHeroesForHeroMeetPartner)">Найти</button>
<button type="button" class="btn" onclick="withAction(loadRecentHeroesForHeroMeetPartner)">50 последних</button>
</div>
</div>
<div class="muted" style="margin:6px 0;font-size:12px">Партнёр: ${hmPick.otherId ? `<strong>ID ${e(hmPick.otherId)}</strong> ${e(hmPick.pickName || "")}` : "— клик по строке (не текущий герой)"}</div>
<div class="list" style="max-height:180px;margin-bottom:8px">${hmRowsHtml}</div>
<button type="button" class="btn" onclick="withAction(startHeroMeetAdmin)" title="Телепорт выбранного партнёра к этому герою и старт встречи">Старт встречи героев</button>
</div>
<p class="muted" style="margin-top:8px;margin-bottom:0">Roadside / adventure: герой жив, не в бою; adventure — <kbd>StateWalking</kbd> на дороге.</p> <p class="muted" style="margin-top:8px;margin-bottom:0">Roadside / adventure: герой жив, не в бою; adventure — <kbd>StateWalking</kbd> на дороге.</p>
<div class="hero-teleport-row"> <div class="hero-teleport-row">
<div><label class="muted">Teleport</label><br /><button type="button" class="btn" onclick="withAction(loadTeleportTowns)">Load towns</button></div> <div><label class="muted">Teleport</label><br /><button type="button" class="btn" onclick="withAction(loadTeleportTowns)">Load towns</button></div>
@ -2748,6 +2834,10 @@
window.searchHeroesForCombatSim = searchHeroesForCombatSim; window.searchHeroesForCombatSim = searchHeroesForCombatSim;
window.loadRecentHeroesForCombatSim = loadRecentHeroesForCombatSim; window.loadRecentHeroesForCombatSim = loadRecentHeroesForCombatSim;
window.selectCombatSimHero = selectCombatSimHero; window.selectCombatSimHero = selectCombatSimHero;
window.searchHeroesForHeroMeetPartner = searchHeroesForHeroMeetPartner;
window.loadRecentHeroesForHeroMeetPartner = loadRecentHeroesForHeroMeetPartner;
window.selectHeroMeetPartner = selectHeroMeetPartner;
window.startHeroMeetAdmin = startHeroMeetAdmin;
window.applyCombatSimEnemyFilter = applyCombatSimEnemyFilter; window.applyCombatSimEnemyFilter = applyCombatSimEnemyFilter;
window.onCombatSimEnemyTypeChange = onCombatSimEnemyTypeChange; window.onCombatSimEnemyTypeChange = onCombatSimEnemyTypeChange;
render(); render();

@ -428,6 +428,7 @@ func CheckDeath(hero *model.Hero, now time.Time) bool {
} }
hero.State = model.StateDead hero.State = model.StateDead
return true return true
} }

@ -94,6 +94,9 @@ type Engine struct {
// merchantStock: ephemeral town merchant rows (heroID) until purchase or dialog close. // merchantStock: ephemeral town merchant rows (heroID) until purchase or dialog close.
merchantStock map[int64]*merchantOfferSession 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. // 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, logger: logger,
lastDisconnectedFullSave: make(map[int64]time.Time), lastDisconnectedFullSave: make(map[int64]time.Time),
merchantStock: make(map[int64]*merchantOfferSession), merchantStock: make(map[int64]*merchantOfferSession),
heroMeetLastRoll: make(map[int64]time.Time),
heroMeetLastMsg: make(map[int64]time.Time),
} }
heap.Init(&e.queue) heap.Init(&e.queue)
return e return e
@ -123,6 +128,21 @@ func (e *Engine) GetMovements(heroId int64) *HeroMovement {
return e.movements[heroId] 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. // 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. // 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 { 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 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. // RoadGraph returns the loaded world graph (for admin tools), or nil.
func (e *Engine) RoadGraph() *RoadGraph { func (e *Engine) RoadGraph() *RoadGraph {
e.mu.RLock() e.mu.RLock()
@ -409,6 +450,10 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) {
e.handleTownTourNPCInteractionOpened(msg) e.handleTownTourNPCInteractionOpened(msg)
case "town_tour_npc_interaction_closed": case "town_tour_npc_interaction_closed":
e.handleTownTourNPCInteractionClosed(msg) e.handleTownTourNPCInteractionClosed(msg)
case "hero_meet_send_message":
e.handleHeroMeetSendMessage(msg)
case "hero_meet_end_conversation":
e.handleHeroMeetEndConversation(msg)
default: default:
// Commands like accept_quest, claim_quest, npc_interact etc. // Commands like accept_quest, claim_quest, npc_interact etc.
// are handled by their respective REST handlers for now. // are handled by their respective REST handlers for now.
@ -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) { func (e *Engine) handleRevive(msg IncomingMessage) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@ -674,6 +720,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
Enemy: enemyToInfo(&cs.Enemy), Enemy: enemyToInfo(&cs.Enemy),
}) })
} }
e.pushHeroMeetIfActiveLocked(hero.ID)
} }
return return
} }
@ -719,6 +766,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
Enemy: enemyToInfo(&cs.Enemy), 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). // mergeTownSessionFromRedis overlays a fresher in-town snapshot when Postgres row is stale (e.g. missed town_pause save).

@ -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
}

@ -173,6 +173,13 @@ type townPausePersistSignature struct {
InTownVisitName string InTownVisitName string
InTownVisitType string InTownVisitType string
InTownLastNPCLinger time.Time 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 { func npcQueueFingerprint(q []int64) uint64 {
@ -902,6 +909,11 @@ func excursionArrivalEpsilon() float64 {
return eps 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). // stepTowardWorldPoint moves CurrentX/Y toward (tx, ty) at speed (world units per second).
// Uses the same arrival epsilon as excursion attractors. // Uses the same arrival epsilon as excursion attractors.
func (hm *HeroMovement) stepTowardWorldPoint(dt float64, tx, ty, speed float64) bool { 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.TownTourPhase = hm.Excursion.TownTourPhase
hm.Hero.TownTourNpcID = hm.Excursion.TownTourNpcID hm.Hero.TownTourNpcID = hm.Excursion.TownTourNpcID
hm.Hero.TownTourExitPending = hm.Excursion.TownExitPending hm.Hero.TownTourExitPending = hm.Excursion.TownExitPending
} else if hm.Excursion.Kind == model.ExcursionKindHeroMeet {
hm.Hero.ExcursionPhase = hm.Excursion.Phase
} else { } else {
hm.Hero.ExcursionPhase = hm.Excursion.Phase hm.Hero.ExcursionPhase = hm.Excursion.Phase
} }
@ -1432,6 +1446,14 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig.InTownVisitType = hm.TownVisitNPCType sig.InTownVisitType = hm.TownVisitNPCType
sig.InTownLastNPCLinger = hm.TownLastNPCLingerUntil 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 return sig
} }
@ -1571,6 +1593,29 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
ep.TownRestUntil = &t 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 return ep
} }
@ -1685,6 +1730,26 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
hm.Excursion.TownRestUntil = *ep.TownRestUntil 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). // 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) { func (hm *HeroMovement) beginRoadsideRest(now time.Time) {
cfg := tuning.Get() cfg := tuning.Get()
hm.State = model.StateResting hm.State = model.StateResting
@ -2165,6 +2252,12 @@ func ProcessSingleHeroMovementTick(
return return
case model.StateWalking: 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() cfg := tuning.Get()
hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2 hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2
if hadNoRoad { 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) --- // --- Active adventure excursion (attractor movement while walking) ---
if hm.Excursion.Active() && hm.Excursion.Kind == model.ExcursionKindAdventure { if hm.Excursion.Active() && hm.Excursion.Kind == model.ExcursionKindAdventure {
dtAdv := now.Sub(hm.LastMoveTick).Seconds() dtAdv := now.Sub(hm.LastMoveTick).Seconds()

@ -102,17 +102,17 @@ func TestBuildEnemyInstanceForLevel_XPPerLevelRampsFrom10(t *testing.T) {
XPPerLevel: 4, XPPerLevel: 4,
IsElite: false, IsElite: false,
} }
early := BuildEnemyInstanceForLevel(tmpl, 6) early := BuildEnemyInstanceForLevel(tmpl, 6, nil)
if early.XPReward != 1 { if early.XPReward != 1 {
t.Fatalf("normal mob instance L6: want base XP only (no per-level ramp), got %d", early.XPReward) 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 { if mid.XPReward <= 1 {
t.Fatalf("normal mob instance L12: want xp_per_level applied, got %d", mid.XPReward) t.Fatalf("normal mob instance L12: want xp_per_level applied, got %d", mid.XPReward)
} }
elite := tmpl elite := tmpl
elite.IsElite = true elite.IsElite = true
el := BuildEnemyInstanceForLevel(elite, 5) el := BuildEnemyInstanceForLevel(elite, 5, nil)
if el.XPReward <= 1 { if el.XPReward <= 1 {
t.Fatalf("elite instance L5: want xp_per_level even before 10, got %d", el.XPReward) 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, Attack: 10,
Defense: 4, Defense: 4,
} }
out := BuildEnemyInstanceForLevel(tmpl, 1) out := BuildEnemyInstanceForLevel(tmpl, 1, nil)
if out.MaxHP != 100 || out.HP != 100 { if out.MaxHP != 100 || out.HP != 100 {
t.Fatalf("MaxHP/HP: got %d/%d want 100/100", out.MaxHP, out.HP) t.Fatalf("MaxHP/HP: got %d/%d want 100/100", out.MaxHP, out.HP)
} }

@ -2138,6 +2138,54 @@ func (h *AdminHandler) TownTourApproachNPC(w http.ResponseWriter, r *http.Reques
h.writeAdminHeroDetail(w, heroAfter) 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). // ForceLeaveTown is an alias for the unified stop-rest flow (see StopHeroRest).
// POST /admin/heroes/{heroId}/leave-town // POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {

@ -141,6 +141,9 @@ func resolveTelegramID(r *http.Request) (int64, bool) {
if id, ok := TelegramIDFromContext(r.Context()); ok { if id, ok := TelegramIDFromContext(r.Context()); ok {
return id, true return id, true
} }
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. // Dev fallback: accept telegramId query param.
idStr := r.URL.Query().Get("telegramId") idStr := r.URL.Query().Get("telegramId")
if idStr != "" { if idStr != "" {
@ -150,10 +153,9 @@ func resolveTelegramID(r *http.Request) (int64, bool) {
} }
} }
// Localhost fallback: default to telegram_id 1 for testing. // 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") {
return 1, true return 1, true
} }
return 0, false return 0, false
} }
@ -313,17 +315,17 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return return
} }
hero, err := h.store.GetByTelegramID(r.Context(), telegramID) if h.engine == nil {
if err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{
h.logger.Error("failed to get hero for revive", "telegram_id", telegramID, "error", err) "error": "game engine unavailable",
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
}) })
return return
} }
hero := h.engine.LiveHeroByTelegramID(telegramID)
if hero == nil { if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "hero not found", "error": "no active hero session; connect to revive",
}) })
return return
} }
@ -360,9 +362,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return 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.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP)
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}}) 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 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 { if err != nil {
h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err) h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -1640,6 +1647,10 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) {
return return
} }
if h.engine != nil {
h.engine.OverlayResidentWorldPositionsOnNearby(nearby)
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"heroes": nearby, "heroes": nearby,
}) })

@ -39,6 +39,8 @@ const (
LogPhraseCombatEnemyHit = "log.combat.enemy_hit" LogPhraseCombatEnemyHit = "log.combat.enemy_hit"
LogPhraseCombatEnemyBlock = "log.combat.enemy_block" LogPhraseCombatEnemyBlock = "log.combat.enemy_block"
LogPhraseCombatDebuffSuffix = "log.combat.debuff_suffix" 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". // Town visit line slugs per NPC kind (order = timed line 0..5). Unknown npcType uses generic slugs with key prefix "generic".

@ -22,8 +22,18 @@ const (
ExcursionKindRoadside ExcursionKind = "roadside" ExcursionKindRoadside ExcursionKind = "roadside"
ExcursionKindAdventure ExcursionKind = "adventure" ExcursionKindAdventure ExcursionKind = "adventure"
ExcursionKindTown ExcursionKind = "town" 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). // TownTourPhase is the sub-state machine while ExcursionKind == town (StateInTown).
type TownTourPhase string type TownTourPhase string
@ -86,6 +96,22 @@ type ExcursionSession struct {
TownTourDialogOpen bool TownTourDialogOpen bool
// Client has NPCInteraction panel open — shifts service deadline; with dialog shifts welcome too. // Client has NPCInteraction panel open — shifts service deadline; with dialog shifts welcome too.
TownTourInteractionOpen bool 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. // Active reports whether an excursion session is in progress.
@ -96,6 +122,17 @@ func (s *ExcursionSession) Active() bool {
if s.Kind == ExcursionKindTown { if s.Kind == ExcursionKindTown {
return s.TownTourPhase != "" 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 return s.Phase != ExcursionNone
} }
@ -131,4 +168,18 @@ type ExcursionPersisted struct {
TownExitPending bool `json:"townExitPending,omitempty"` TownExitPending bool `json:"townExitPending,omitempty"`
TownTourDialogOpen bool `json:"townTourDialogOpen,omitempty"` TownTourDialogOpen bool `json:"townTourDialogOpen,omitempty"`
TownTourInteractionOpen bool `json:"townTourInteractionOpen,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"`
} }

@ -0,0 +1,29 @@
package model
// HeroMeetAutoLineSlugs are stable ids for auto-dialogue (client localizes hero_meet.auto.<slug>).
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
}

@ -283,3 +283,41 @@ type ExcursionPhasePayload struct {
// ExcursionEndPayload is sent when the mini-adventure completes. // ExcursionEndPayload is sent when the mini-adventure completes.
type ExcursionEndPayload struct{} 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"`
}

@ -70,3 +70,23 @@ func HeroNameIsProfane(name string) bool {
} }
return false 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
}

@ -99,6 +99,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest) r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest)
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown) r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown)
r.Post("/heroes/{heroId}/town-tour-approach-npc", adminH.TownTourApproachNPC) r.Post("/heroes/{heroId}/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}/trigger-random-encounter", adminH.TriggerRandomEncounter)
r.Post("/heroes/{heroId}/kill-current-enemy", adminH.KillCurrentEnemy) r.Post("/heroes/{heroId}/kill-current-enemy", adminH.KillCurrentEnemy)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear) r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)

@ -268,6 +268,23 @@ type Values struct {
// RoadsideRestDepthWorldUnits is the perpendicular offset from road during roadside rest. // RoadsideRestDepthWorldUnits is the perpendicular offset from road during roadside rest.
RoadsideRestDepthWorldUnits float64 `json:"roadsideRestDepthWorldUnits"` 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 { func DefaultValues() Values {
@ -460,6 +477,19 @@ func DefaultValues() Values {
AdventureRestHpPerS: 0.004, AdventureRestHpPerS: 0.004,
RoadsideRestDepthWorldUnits: 12.0, 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,
} }
} }

@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"pixi.js": "^8.6.6", "pixi.js": "^8.6.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"yaml": "^2.7.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@ -2972,6 +2973,21 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

@ -1,6 +1,15 @@
import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react'; import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { GameEngine } from './game/engine'; 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 type { NPCEncounterEvent } from './game/types';
import { GameWebSocket } from './network/websocket'; import { GameWebSocket } from './network/websocket';
import { import {
@ -86,8 +95,9 @@ import { AchievementsPanel } from './ui/AchievementsPanel';
import { Minimap } from './ui/Minimap'; import { Minimap } from './ui/Minimap';
import { NPCInteraction } from './ui/NPCInteraction'; import { NPCInteraction } from './ui/NPCInteraction';
import { WanderingNPCPopup } from './ui/WanderingNPCPopup'; import { WanderingNPCPopup } from './ui/WanderingNPCPopup';
import { HeroMeetPanel } from './ui/HeroMeetPanel';
import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n'; import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n';
import { randomRoadsideThoughtLine } from './i18n/loadLocales'; import { adventureLogTemplate, randomRoadsideThoughtLine } from './i18n/loadLocales';
const appStyle: CSSProperties = { const appStyle: CSSProperties = {
width: '100%', width: '100%',
@ -298,7 +308,8 @@ function heroResponseToState(res: HeroResponse): HeroState {
excursionKind: excursionKind:
res.excursionKind === 'roadside' || res.excursionKind === 'roadside' ||
res.excursionKind === 'adventure' || res.excursionKind === 'adventure' ||
res.excursionKind === 'town' res.excursionKind === 'town' ||
res.excursionKind === 'hero_meet'
? (res.excursionKind as HeroState['excursionKind']) ? (res.excursionKind as HeroState['excursionKind'])
: undefined, : undefined,
townTourPhase: res.townTourPhase, townTourPhase: res.townTourPhase,
@ -435,6 +446,12 @@ export function App() {
// Wandering NPC encounter state // Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null); const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
const [heroMeetPanel, setHeroMeetPanel] = useState<{
partnerName: string;
anySideOnline: boolean;
maxChars: number;
} | null>(null);
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle); const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle);
// Achievements // Achievements
const [achievements, setAchievements] = useState<Achievement[]>([]); const [achievements, setAchievements] = useState<Achievement[]>([]);
@ -1062,6 +1079,44 @@ export function App() {
hapticNotification('success'); hapticNotification('success');
refreshEquipment(); 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 ---- // ---- Telegram Theme Listener ----
@ -1454,6 +1509,15 @@ export function App() {
}} }}
/> />
{heroMeetPanel && wsRef.current ? (
<HeroMeetPanel
partnerName={heroMeetPanel.partnerName}
anySideOnline={heroMeetPanel.anySideOnline}
ws={wsRef.current}
maxChars={heroMeetPanel.maxChars}
/>
) : null}
{gameState.hero && ( {gameState.hero && (
<HeroSheetModal <HeroSheetModal
open={heroSheetOpen} open={heroSheetOpen}

@ -388,6 +388,20 @@ export function formatAdventureLogEvent(
return dialogueText(locale, WANDERING_MERCHANT_DIALOGUE_KEY, legacyMessage ?? ''); return dialogueText(locale, WANDERING_MERCHANT_DIALOGUE_KEY, legacyMessage ?? '');
} }
if (phraseKey === 'log.hero_meet.scripted_line') {
const speaker = strArg(rawArgs, 'speaker');
const lineKey = strArg(rawArgs, 'lineKey');
const lineBody = adventureLogTemplate(locale, lineKey) || lineKey;
return `${speaker}: ${lineBody}`;
}
if (phraseKey === 'log.hero_meet.player_said') {
const speaker = strArg(rawArgs, 'speaker');
const text = strArg(rawArgs, 'text');
const tmpl = adventureLogTemplate(locale, 'log.hero_meet.player_said');
if (tmpl) return t(tmpl, { speaker, text });
return `${speaker}: ${text}`;
}
const roadside = dynamicRoadsideLine(locale, phraseKey); const roadside = dynamicRoadsideLine(locale, phraseKey);
if (roadside !== undefined) return roadside; if (roadside !== undefined) return roadside;

@ -32,6 +32,11 @@ export function isAdventureLogCombatCode(code: string | undefined): boolean {
return code.startsWith('log.combat.'); return code.startsWith('log.combat.');
} }
/** Meet chat is shown only via `hero_meet_line` → meet bubbles; skip duplicate thought bubble. */
export function isAdventureLogHeroMeetCode(code: string | undefined): boolean {
return code === 'log.hero_meet.player_said' || code === 'log.hero_meet.scripted_line';
}
export function shouldSuppressThoughtBubblePayload(p: { export function shouldSuppressThoughtBubblePayload(p: {
message?: string; message?: string;
event?: { code?: string }; event?: { code?: string };
@ -40,5 +45,6 @@ export function shouldSuppressThoughtBubblePayload(p: {
if (msg.startsWith(AH_ENC_PREFIX) || msg.startsWith(AH_BAT_PREFIX)) return true; if (msg.startsWith(AH_ENC_PREFIX) || msg.startsWith(AH_BAT_PREFIX)) return true;
if (isAdventureLogEncounterCode(p.event?.code)) return true; if (isAdventureLogEncounterCode(p.event?.code)) return true;
if (isAdventureLogCombatCode(p.event?.code)) return true; if (isAdventureLogCombatCode(p.event?.code)) return true;
if (isAdventureLogHeroMeetCode(p.event?.code)) return true;
return false; return false;
} }

@ -92,6 +92,23 @@ export class GameEngine {
/** Nearby heroes from the shared world (polled periodically) */ /** Nearby heroes from the shared world (polled periodically) */
private _nearbyHeroes: NearbyHeroData[] = []; private _nearbyHeroes: NearbyHeroData[] = [];
/** Meet partner world snapshot (active session or linger ghost). */
private _heroMeetOverlay: NearbyHeroData | null = null;
private _heroMeetLingerEndMs = 0;
private _meetBubbleSelf: {
text: string;
startMs: number;
player: boolean;
/** Typed by local player (not auto/scripted); distinct balloon background on canvas */
ownPlayerMessage: boolean;
} | null = null;
private _meetBubbleOther: {
text: string;
startMs: number;
player: boolean;
ownPlayerMessage: boolean;
} | null = null;
/** Debuff full-duration ms from last hero snapshot (`debuffCatalog`); used when WS omits durationMs. */ /** Debuff full-duration ms from last hero snapshot (`debuffCatalog`); used when WS omits durationMs. */
private _debuffDurationMsByType: Partial<Record<DebuffType, number>> = {}; private _debuffDurationMsByType: Partial<Record<DebuffType, number>> = {};
@ -175,6 +192,80 @@ export class GameEngine {
this._nearbyHeroes = heroes; 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 ---- // ---- Server State Application ----
/** /**
@ -235,6 +326,24 @@ export class GameEngine {
targetY: number, targetY: number,
speed: number, speed: number,
): void { ): 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._prevPositionX = this._heroDisplayX;
this._prevPositionY = this._heroDisplayY; this._prevPositionY = this._heroDisplayY;
this._targetPositionX = x; this._targetPositionX = x;
@ -405,6 +514,22 @@ export class GameEngine {
// Display position: follow hero_move interpolation and applyPositionSync only. // Display position: follow hero_move interpolation and applyPositionSync only.
// Do not snap here on hero_state vs last move — server position includes // Do not snap here on hero_state vs last move — server position includes
// excursion/rest offsets and ordering with hero_move caused visible teleports. // 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(); this._notifyStateChange();
} }
@ -783,6 +908,13 @@ export class GameEngine {
* position over that interval. * position over that interval.
*/ */
private _interpolatePosition(): void { 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 = const elapsed =
(performance.now() - this._lastMoveUpdateTime) / 1000; (performance.now() - this._lastMoveUpdateTime) / 1000;
const t = Math.min(elapsed / MOVE_UPDATE_INTERVAL_S, 1.0); const t = Math.min(elapsed / MOVE_UPDATE_INTERVAL_S, 1.0);
@ -828,7 +960,13 @@ export class GameEngine {
if (state.hero) { if (state.hero) {
const isWalking = state.phase === GamePhase.Walking; const isWalking = state.phase === GamePhase.Walking;
const isFighting = state.phase === GamePhase.Fighting; const isFighting = state.phase === GamePhase.Fighting;
const animPhase = isWalking const inHeroMeetDialogue =
state.hero.excursionKind === 'hero_meet' &&
state.hero.excursionPhase?.toLowerCase() === 'meet';
const animPhase =
inHeroMeetDialogue && isWalking
? 'idle'
: isWalking
? 'walk' ? 'walk'
: isFighting : isFighting
? 'fight' ? 'fight'
@ -883,13 +1021,53 @@ export class GameEngine {
this.renderer.clearNPCs(); this.renderer.clearNPCs();
} }
// Draw nearby heroes from the shared world // Draw nearby heroes from the shared world (meet partner uses full sprite below, not diamonds)
if (this._nearbyHeroes.length > 0) { const nearbyMerged = this._mergedNearbyHeroes(now);
this.renderer.drawNearbyHeroes(this._nearbyHeroes, now); if (nearbyMerged.length > 0) {
this.renderer.drawNearbyHeroes(nearbyMerged, now);
} else { } else {
this.renderer.clearNearbyHeroes(); 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 // Draw enemy during combat or death
const showEnemy = const showEnemy =
state.enemy && state.enemy &&

@ -76,11 +76,18 @@ export class GameRenderer {
// Reusable Graphics objects (avoid GC in hot path) // Reusable Graphics objects (avoid GC in hot path)
private _groundGfx: Graphics | null = null; private _groundGfx: Graphics | null = null;
private _heroGfx: 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). */ /** Tent + campfire while resting in the wild phase (roadside / adventure inline). */
private _restCampGfx: Graphics | null = null; private _restCampGfx: Graphics | null = null;
private _enemyGfx: Graphics | null = null; private _enemyGfx: Graphics | null = null;
private _thoughtGfx: Graphics | null = null; private _thoughtGfx: Graphics | null = null;
private _thoughtText: Text | null = null; private _thoughtText: Text | null = null;
private _meetBubbleSlots: Array<{
gfx: Graphics;
txt: Text;
}> = [];
private _heroNameText: Text | null = null; private _heroNameText: Text | null = null;
private _heroName = ''; private _heroName = '';
@ -317,6 +324,9 @@ export class GameRenderer {
this._heroGfx = new Graphics(); this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx); this.entityLayer.addChild(this._heroGfx);
this._meetPartnerGfx = new Graphics();
this.entityLayer.addChild(this._meetPartnerGfx);
this._restCampGfx = new Graphics(); this._restCampGfx = new Graphics();
this.entityLayer.addChild(this._restCampGfx); this.entityLayer.addChild(this._restCampGfx);
@ -341,6 +351,26 @@ export class GameRenderer {
this._thoughtText.visible = false; this._thoughtText.visible = false;
this.entityLayer.addChild(this._thoughtText); 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({ this._heroNameText = new Text({
text: '', text: '',
style: new TextStyle({ style: new TextStyle({
@ -355,6 +385,20 @@ export class GameRenderer {
this._heroNameText.visible = false; this._heroNameText.visible = false;
this.entityLayer.addChild(this._heroNameText); 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) // Town graphics (drawn between ground and entity layers)
this._townGfx = new Graphics(); this._townGfx = new Graphics();
this.groundLayer.addChild(this._townGfx); 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 { private paintHeroSilhouette(
const gfx = this._heroGfx; gfx: Graphics,
if (!gfx) return; wx: number,
wy: number,
phase: 'walk' | 'fight' | 'idle',
now: number,
variant: 'self' | 'meet_partner',
): { cx: number; cy: number; iso: ScreenPoint } {
gfx.clear(); gfx.clear();
const iso = worldToScreen(wx, wy); const iso = worldToScreen(wx, wy);
let yOffset = 0; let yOffset = 0;
if (phase === 'walk') { if (phase === 'walk') {
yOffset = Math.sin(now * 0.006) * 3; yOffset = Math.sin(now * 0.006) * 3;
} else if (phase === 'fight') { } else if (phase === 'fight') {
yOffset = Math.sin(now * 0.012) * 2; yOffset = Math.sin(now * 0.012) * 2;
} }
const cx = iso.x; const cx = iso.x;
const cy = iso.y + yOffset; 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.ellipse(cx, cy + 10, 16, 5);
gfx.fill({ color: 0x000000, alpha: 0.28 }); 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.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.roundRect(cx - 9, cy - 18, 18, 22, 4);
gfx.fill({ color: 0x3a5a8a, alpha: 0.98 }); gfx.fill({ color: tunic, alpha: 0.98 });
gfx.stroke({ color: 0x1a3050, width: 1.2 }); gfx.stroke({ color: tunicStroke, width: 1.2 });
// Belt
gfx.rect(cx - 9, cy + 2, 18, 4); gfx.rect(cx - 9, cy + 2, 18, 4);
gfx.fill({ color: 0x3a2818, alpha: 0.95 }); gfx.fill({ color: 0x3a2818, alpha: 0.95 });
// Head / helm
gfx.roundRect(cx - 7, cy - 30, 14, 13, 5); gfx.roundRect(cx - 7, cy - 30, 14, 13, 5);
gfx.fill({ color: 0x8899aa, alpha: 0.98 }); gfx.fill({ color: helm, alpha: 0.98 });
gfx.stroke({ color: 0x556070, width: 1 }); gfx.stroke({ color: helmStroke, width: 1 });
// Blade (readability at small zoom)
gfx.rect(cx + 8, cy - 22, 3, 20); gfx.rect(cx + 8, cy - 22, 3, 20);
gfx.fill({ color: 0xc8d8e8, alpha: 0.95 }); gfx.fill({ color: 0xc8d8e8, alpha: 0.95 });
gfx.rect(cx + 8, cy - 4, 6, 3); gfx.rect(cx + 8, cy - 4, 6, 3);
@ -547,19 +593,49 @@ export class GameRenderer {
} }
gfx.zIndex = cy + 100; 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; const nameTxt = this._heroNameText;
if (nameTxt && this._heroName) { if (nameTxt && this._heroName) {
nameTxt.text = this._heroName; nameTxt.text = this._heroName;
nameTxt.x = iso.x; nameTxt.x = iso.x;
// Default: above hero head; drawThoughtBubble will reposition if active
nameTxt.y = iso.y - 42; nameTxt.y = iso.y - 42;
nameTxt.visible = true; nameTxt.visible = true;
nameTxt.zIndex = cy + 199; 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). * 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. * 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 */ /** Clear nearby hero visuals when there are none to render */
clearNearbyHeroes(): void { clearNearbyHeroes(): void {
if (this._nearbyHeroGfx) this._nearbyHeroGfx.clear(); if (this._nearbyHeroGfx) this._nearbyHeroGfx.clear();

@ -138,8 +138,8 @@ export interface HeroState {
restKind?: string; restKind?: string;
/** Mini-adventure leg: "out" | "wild" | "return" when excursion active */ /** Mini-adventure leg: "out" | "wild" | "return" when excursion active */
excursionPhase?: string; excursionPhase?: string;
/** Attractor excursion mode from server: roadside rest vs walking adventure vs in-town tour */ /** Attractor excursion mode from server: roadside rest vs walking adventure vs in-town tour vs paired meet */
excursionKind?: 'roadside' | 'adventure' | 'town'; excursionKind?: 'roadside' | 'adventure' | 'town' | 'hero_meet';
/** Sub-phase during `excursionKind: town` (server-driven NPC UI). */ /** Sub-phase during `excursionKind: town` (server-driven NPC UI). */
townTourPhase?: string; townTourPhase?: string;
townTourNpcId?: number; townTourNpcId?: number;
@ -469,6 +469,9 @@ export type ServerMessageType =
| 'town_tour_service_end' | 'town_tour_service_end'
| 'npc_encounter' | 'npc_encounter'
| 'npc_encounter_end' | 'npc_encounter_end'
| 'hero_meet_start'
| 'hero_meet_line'
| 'hero_meet_end'
| 'level_up' | 'level_up'
| 'equipment_change' | 'equipment_change'
| 'potion_collected' | 'potion_collected'
@ -650,6 +653,35 @@ export interface NPCEncounterEndPayload {
reason: 'timeout' | 'declined' | string; 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 { export interface LevelUpPayload {
newLevel: number; newLevel: number;
statChanges: { statChanges: {

@ -30,6 +30,9 @@ import type {
LootBonusItemSlot, LootBonusItemSlot,
MerchantLootPayload, MerchantLootPayload,
DebuffAppliedPayload, DebuffAppliedPayload,
HeroMeetStartPayload,
HeroMeetLinePayload,
HeroMeetEndPayload,
} from './types'; } from './types';
import { DebuffType, Rarity } from './types'; import { DebuffType, Rarity } from './types';
// ---- Callback types for UI layer (App.tsx) ---- // ---- Callback types for UI layer (App.tsx) ----
@ -60,6 +63,9 @@ export interface WSHandlerCallbacks {
onError?: (payload: ServerErrorPayload) => void; onError?: (payload: ServerErrorPayload) => void;
onHeroStateReceived?: (hero: Record<string, unknown>) => void; onHeroStateReceived?: (hero: Record<string, unknown>) => void;
onMerchantLoot?: (payload: MerchantLootPayload) => 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); console.warn('[WS] Server error:', p.code, p.message);
callbacks.onError?.(p); 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 ---- // ---- Client -> Server command helpers ----
@ -320,6 +343,14 @@ export function sendTownTourNPCInteractionClosed(ws: GameWebSocket): void {
ws.send('town_tour_npc_interaction_closed', {}); 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. * Build a LootDrop from combat_end payload for the loot popup UI.
*/ */

@ -111,6 +111,11 @@ ui:
healToFullForGold: Heal to Full ({cost}g) healToFullForGold: Heal to Full ({cost}g)
viewQuests: View Quests viewQuests: View Quests
npcInteractTalk: Talk npcInteractTalk: Talk
heroMeetTitle: Meeting
heroMeetPlaceholder: Say something… (max {max} characters)
heroMeetSend: Send
heroMeetEndConversation: End conversation
heroMeetCharCount: '{current}/{max}'
shopHealingPotionName: Healing Potion shopHealingPotionName: Healing Potion
shopHealingPotionDesc: Restores health. Always handy in a pinch. shopHealingPotionDesc: Restores health. Always handy in a pinch.
shopFullHealName: Full Heal shopFullHealName: Full Heal
@ -221,6 +226,19 @@ adventure_log:
log.combat.hero_stun: You are stunned and cannot attack.{debuffPart} 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_hit: '{enemy} hits you for {damage} damage{crit}{debuffPart}'
log.combat.enemy_block: "You block {enemy}'s attack.{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: achievements:
first_blood: First Blood first_blood: First Blood

@ -111,6 +111,11 @@ ui:
healToFullForGold: 'Полное лечение ({cost}з)' healToFullForGold: 'Полное лечение ({cost}з)'
viewQuests: 'Квесты' viewQuests: 'Квесты'
npcInteractTalk: 'Поговорить' npcInteractTalk: 'Поговорить'
heroMeetTitle: 'Встреча'
heroMeetPlaceholder: 'Напишите сообщение… (до {max} символов)'
heroMeetSend: 'Отправить'
heroMeetEndConversation: 'Завершить разговор'
heroMeetCharCount: '{current}/{max}'
shopHealingPotionName: 'Зелье лечения' shopHealingPotionName: 'Зелье лечения'
shopHealingPotionDesc: 'Восстанавливает здоровье. Всегда полезно.' shopHealingPotionDesc: 'Восстанавливает здоровье. Всегда полезно.'
shopFullHealName: 'Полное лечение' shopFullHealName: 'Полное лечение'
@ -221,6 +226,19 @@ adventure_log:
log.combat.hero_stun: 'Вы оглушены и не можете атаковать.{debuffPart}' log.combat.hero_stun: 'Вы оглушены и не можете атаковать.{debuffPart}'
log.combat.enemy_hit: '{enemy} бьёт вас на {damage} урона{crit}{debuffPart}' log.combat.enemy_hit: '{enemy} бьёт вас на {damage} урона{crit}{debuffPart}'
log.combat.enemy_block: 'Вы блокируете атаку {enemy}.{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: achievements:
first_blood: 'Первая кровь' first_blood: 'Первая кровь'

@ -111,6 +111,11 @@ export interface Translations {
healToFullForGold: string; healToFullForGold: string;
viewQuests: string; viewQuests: string;
npcInteractTalk: string; npcInteractTalk: string;
heroMeetTitle: string;
heroMeetPlaceholder: string;
heroMeetSend: string;
heroMeetEndConversation: string;
heroMeetCharCount: string;
shopHealingPotionName: string; shopHealingPotionName: string;
shopHealingPotionDesc: string; shopHealingPotionDesc: string;
shopFullHealName: string; shopFullHealName: string;

@ -112,8 +112,31 @@ export function getTelegramInitData(): string {
return getTelegramWebApp()?.initData ?? ''; 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 { export function getTelegramUserId(): number | null {
const fromQuery = readDevTelegramIdFromQuery();
if (fromQuery != null) return fromQuery;
const tg = getTelegramWebApp(); const tg = getTelegramWebApp();
if (!tg) return null; if (!tg) return null;
const user = tg.initDataUnsafe?.user as { id?: number } | undefined; const user = tg.initDataUnsafe?.user as { id?: number } | undefined;

@ -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 (
<div style={panelStyle}>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: '#e0f7fa',
marginBottom: 8,
textAlign: 'center',
}}
>
{tr.heroMeetTitle}: {partnerName}
</div>
{anySideOnline ? (
<>
<textarea
value={text}
maxLength={maxChars}
onChange={(e) => setText(e.target.value)}
placeholder={interpolate(tr.heroMeetPlaceholder, { max: maxChars })}
rows={2}
style={{
width: '100%',
boxSizing: 'border-box',
resize: 'none',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.15)',
background: 'rgba(0,0,0,0.35)',
color: '#e8f8ff',
fontSize: 13,
padding: '8px 10px',
marginBottom: 6,
}}
/>
<div style={{ fontSize: 10, color: '#7dd3fc', marginBottom: 6, textAlign: 'right' }}>
{interpolate(tr.heroMeetCharCount, { current: len, max: maxChars })}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={send}
style={{
flex: 1,
padding: '8px 10px',
borderRadius: 8,
border: 'none',
fontWeight: 600,
fontSize: 12,
cursor: 'pointer',
background: 'rgba(34, 211, 238, 0.25)',
color: '#a5f3fc',
}}
>
{tr.heroMeetSend}
</button>
<button
type="button"
onClick={endConv}
style={{
flex: 1,
padding: '8px 10px',
borderRadius: 8,
border: '1px solid rgba(248, 113, 113, 0.4)',
fontWeight: 600,
fontSize: 12,
cursor: 'pointer',
background: 'rgba(248, 113, 113, 0.12)',
color: '#fecaca',
}}
>
{tr.heroMeetEndConversation}
</button>
</div>
</>
) : (
<div style={{ fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
{partnerName}
</div>
)}
</div>
);
}
function interpolate(template: string, vars: Record<string, string | number>): string {
let s = template;
for (const [k, v] of Object.entries(vars)) {
const needle = `{${k}}`;
s = s.split(needle).join(String(v));
}
return s;
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

@ -0,0 +1,37 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': '/src',
},
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
build: {
target: 'es2020',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
pixi: ['pixi.js'],
react: ['react', 'react-dom'],
},
},
},
},
});

@ -25,11 +25,13 @@ param(
"stop-adventure", "stop-adventure",
"stop-rest", "stop-rest",
"time-pause", "time-pause",
"time-resume" "time-resume",
"start-hero-meet"
)] )]
[string]$Command, [string]$Command,
[long]$HeroId, [long]$HeroId,
[long]$OtherHeroId,
[int]$TownId, [int]$TownId,
[int]$Level, [int]$Level,
[long]$Gold, [long]$Gold,
@ -192,6 +194,11 @@ switch ($Command) {
"time-resume" { "time-resume" {
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/time/resume" -Body @{} $result = Invoke-AdminRequest -Method "POST" -Path "/admin/time/resume" -Body @{}
} }
"start-hero-meet" {
Require-Value -Name "HeroId" -Value $HeroId
Require-Value -Name "OtherHeroId" -Value $OtherHeroId
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/start-hero-meet" -Body @{ otherHeroId = $OtherHeroId }
}
default { default {
throw "Unsupported command: $Command" throw "Unsupported command: $Command"
} }

Loading…
Cancel
Save