Compare commits

..

4 Commits

@ -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();
@ -1138,7 +1189,6 @@
const live = h.adminLiveMovement; const live = h.adminLiveMovement;
const tp = h.townPause; const tp = h.townPause;
const rows = []; const rows = [];
rows.push(`<div class="kv"><kbd>moveState</kbd><div>${e(h.moveState)}</div></div>`);
if (h.currentTownId != null) rows.push(`<div class="kv"><kbd>currentTownId</kbd><div>${e(h.currentTownId)}</div></div>`); if (h.currentTownId != null) rows.push(`<div class="kv"><kbd>currentTownId</kbd><div>${e(h.currentTownId)}</div></div>`);
if (h.destinationTownId != null) rows.push(`<div class="kv"><kbd>destinationTownId</kbd><div>${e(h.destinationTownId)}</div></div>`); if (h.destinationTownId != null) rows.push(`<div class="kv"><kbd>destinationTownId</kbd><div>${e(h.destinationTownId)}</div></div>`);
if (h.restKind) rows.push(`<div class="kv"><kbd>restKind</kbd><div>${e(h.restKind)}</div></div>`); if (h.restKind) rows.push(`<div class="kv"><kbd>restKind</kbd><div>${e(h.restKind)}</div></div>`);
@ -1227,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 = [];
@ -2093,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 = "";
@ -2288,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>
@ -2749,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();

@ -0,0 +1,65 @@
// Command seedrandomheroes inserts N level-1 heroes with random spawn/starter gear (not a migration).
// Usage: from backend/: go run ./cmd/seedrandomheroes -n 500
// Requires DB env vars (same as server): DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"math/rand"
"os"
"time"
"github.com/denisovdennis/autohero/internal/config"
"github.com/denisovdennis/autohero/internal/storage"
)
func main() {
n := flag.Int("n", 500, "number of heroes to create")
telegramBase := flag.Int64("telegram-base", 8_100_000_000_000, "first synthetic telegram_id (each hero gets base+0..n-1)")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg := config.Load()
ctx := context.Background()
pool, err := storage.NewPostgres(ctx, cfg.DB, logger)
if err != nil {
logger.Error("postgres", "error", err)
os.Exit(1)
}
defer pool.Close()
store := storage.NewHeroStore(pool, logger)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
prefixes := []string{
"Aldo", "Bree", "Cade", "Dara", "Ewan", "Fira", "Gorn", "Hett", "Ivor", "Jesa",
"Kael", "Lina", "Miro", "Nyx", "Orin", "Pike", "Quin", "Riva", "Sven", "Tess",
"Ulric", "Venn", "Wren", "Yara", "Zara",
}
var ok, fail int
for i := 0; i < *n; i++ {
tg := *telegramBase + int64(i)
// Unique per batch: idx_heroes_name_lower — include telegramBase so re-runs with new -telegram-base never collide.
name := fmt.Sprintf("%s_%d_%05d", prefixes[rng.Intn(len(prefixes))], *telegramBase, i)
_, err := store.CreateHeroWithSpawn(ctx, tg, name)
if err != nil {
logger.Warn("create failed", "i", i, "telegram_id", tg, "name", name, "error", err)
fail++
continue
}
ok++
if ok%100 == 0 {
logger.Info("progress", "created", ok)
}
}
logger.Info("done", "created", ok, "failed", fail, "requested", *n)
if fail > 0 {
os.Exit(1)
}
}

@ -187,7 +187,7 @@ func main() {
WithRewardStores(gearStore, achievementStore, taskStore). WithRewardStores(gearStore, achievementStore, taskStore).
WithDigestStore(digestStore) WithDigestStore(digestStore)
bootCtx, bootCancel := context.WithTimeout(ctx, 3*time.Minute) bootCtx, bootCancel := context.WithTimeout(ctx, 3*time.Minute)
game.BootstrapResidentHeroes(bootCtx, engine, heroStore, bootstrapSim, 500, logger) game.BootstrapResidentHeroes(bootCtx, engine, heroStore, bootstrapSim, logger)
bootCancel() bootCancel()
// Start game engine (after resident heroes are registered). // Start game engine (after resident heroes are registered).

@ -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).
@ -1974,7 +2046,6 @@ func (e *Engine) mergeTownSessionFromRedis(hero *model.Hero) {
return return
} }
hero.State = model.StateInTown hero.State = model.StateInTown
hero.MoveState = string(model.StateInTown)
hero.TownPause = snap.TownPause hero.TownPause = snap.TownPause
hero.PositionX = snap.PositionX hero.PositionX = snap.PositionX
hero.PositionY = snap.PositionY hero.PositionY = snap.PositionY

@ -16,11 +16,11 @@ import (
// BootstrapResidentHeroes loads heroes whose WebSocket session had ended before this process started, // BootstrapResidentHeroes loads heroes whose WebSocket session had ended before this process started,
// catches up wall time using the same batch path as server-downtime recovery, then registers them // catches up wall time using the same batch path as server-downtime recovery, then registers them
// in the engine so movement and combat continue without a live subscriber. // in the engine so movement and combat continue without a live subscriber.
func BootstrapResidentHeroes(ctx context.Context, e *Engine, heroStore *storage.HeroStore, sim *OfflineSimulator, limit int, logger *slog.Logger) { func BootstrapResidentHeroes(ctx context.Context, e *Engine, heroStore *storage.HeroStore, sim *OfflineSimulator, logger *slog.Logger) {
if e == nil || heroStore == nil || sim == nil { if e == nil || heroStore == nil || sim == nil {
return return
} }
heroes, err := heroStore.ListHeroesForEngineBootstrap(ctx, limit) heroes, err := heroStore.ListHeroesForEngineBootstrap(ctx)
if err != nil { if err != nil {
if logger != nil { if logger != nil {
logger.Error("engine bootstrap: list heroes", "error", err) logger.Error("engine bootstrap: list heroes", "error", err)

@ -0,0 +1,927 @@
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 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 = randomDurationBetweenMs(240_000, 360_000)
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 := randomDurationBetweenMs(240_000, 360_000)
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.RandomHeroMeetAutoPhraseKey()
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 {
@ -1320,7 +1332,6 @@ func (hm *HeroMovement) SyncToHero() {
} else { } else {
hm.Hero.DestinationTownID = nil hm.Hero.DestinationTownID = nil
} }
hm.Hero.MoveState = string(hm.State)
hm.Hero.RestKind = model.RestKindNone hm.Hero.RestKind = model.RestKindNone
if hm.State == model.StateResting { if hm.State == model.StateResting {
if hm.ActiveRestKind != model.RestKindNone { if hm.ActiveRestKind != model.RestKindNone {
@ -1341,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
} }
@ -1433,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
} }
@ -1572,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
} }
@ -1686,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).
@ -1763,10 +1827,9 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv
if now.Before(deadline) { if now.Before(deadline) {
break break
} }
lineIdx := hm.TownVisitLogsEmitted
log(heroID, model.AdventureLogLine{ log(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{ Event: &model.AdventureLogEvent{
Code: model.TownVisitPhraseKey(hm.TownVisitNPCType, lineIdx), Code: model.TownVisitRandomPhraseKey(hm.TownVisitNPCType),
}, },
}) })
hm.TownVisitLogsEmitted++ hm.TownVisitLogsEmitted++
@ -1887,6 +1950,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
@ -2166,6 +2251,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 {
@ -2198,6 +2289,30 @@ 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 {
movePayload := hm.MovePayload(now)
sender.SendToHero(heroID, "hero_move", movePayload)
// Return: partner client must see this hero walking back (same pattern as roadside attractor sync).
if hm.Excursion.Phase == model.ExcursionReturn {
if pid := hm.Excursion.HeroMeetPartnerID; pid != 0 && pid != heroID {
sender.SendToHero(pid, "hero_move", movePayload)
}
}
}
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)
} }

@ -88,7 +88,6 @@ type adminTownTourLiveJSON struct {
// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only). // adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct { type adminLiveMovementJSON struct {
Online bool `json:"online"` Online bool `json:"online"`
MoveState string `json:"moveState,omitempty"`
RestUntil *time.Time `json:"restUntil,omitempty"` RestUntil *time.Time `json:"restUntil,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"` TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"` NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
@ -164,7 +163,6 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
} }
s := &adminLiveMovementJSON{ s := &adminLiveMovementJSON{
Online: true, Online: true,
MoveState: string(hm.State),
} }
if !hm.RestUntil.IsZero() { if !hm.RestUntil.IsZero() {
t := hm.RestUntil t := hm.RestUntil
@ -2140,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) {
@ -3126,7 +3172,6 @@ func applyNewPlayerHeroDefaults(hero *model.Hero) {
hero.ExcursionPhase = model.ExcursionNone hero.ExcursionPhase = model.ExcursionNone
hero.RestKind = model.RestKindNone hero.RestKind = model.RestKindNone
hero.TownPause = nil hero.TownPause = nil
hero.MoveState = string(model.StateWalking)
} }
// resetHeroToLevel1 restores a hero to fresh level 1 defaults, // resetHeroToLevel1 restores a hero to fresh level 1 defaults,

@ -313,17 +313,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 +360,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 +1629,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 +1645,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,
}) })

@ -1,5 +1,7 @@
package model package model
import "math/rand"
// Phrase keys for adventure_log.event_code / WS adventure_log_line.event.code. // Phrase keys for adventure_log.event_code / WS adventure_log_line.event.code.
// No human-readable text on the server — only keys and structured args. // No human-readable text on the server — only keys and structured args.
@ -39,6 +41,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".
@ -50,6 +54,12 @@ var townVisitLineSlugs = map[string][]string{
"rumors_bandits_carts", "rumors_bandits_carts",
"bell_traveler_pack", "bell_traveler_pack",
"step_back_tally_gold", "step_back_tally_gold",
"scale_dust_counter",
"rope_coil_trips_you",
"copper_jingles_pouch",
"foreign_coin_bite",
"no_credit_today",
"closing_soon_maybe",
}, },
"healer": { "healer": {
"linens_herbs_tent", "linens_herbs_tent",
@ -58,6 +68,12 @@ var townVisitLineSlugs = map[string][]string{
"tonic_steams_table", "tonic_steams_table",
"blessings_salves_bandages", "blessings_salves_bandages",
"lighter_under_canvas", "lighter_under_canvas",
"needle_flash_quick",
"wash_basin_cloudy",
"herb_bundle_label_faded",
"whisper_count_pulse",
"lint_free_bandage_brag",
"bitter_tea_offer",
}, },
"quest_giver": { "quest_giver": {
"scrolls_wax_desk", "scrolls_wax_desk",
@ -66,6 +82,12 @@ var townVisitLineSlugs = map[string][]string{
"draft_parchment_smell", "draft_parchment_smell",
"squint_spine_legend", "squint_spine_legend",
"promise_listen_worth_it", "promise_listen_worth_it",
"seal_crack_important",
"chair_squeak_dramatic",
"window_draft_story",
"stamp_ink_thumb",
"reward_bag_heavier",
"last_hero_failed_joke",
}, },
"generic": { "generic": {
"town_noise_blanket", "town_noise_blanket",
@ -74,10 +96,16 @@ var townVisitLineSlugs = map[string][]string{
"strap_tighten_pretend", "strap_tighten_pretend",
"dog_boring_sleeps", "dog_boring_sleeps",
"breathe_ready_move_on", "breathe_ready_move_on",
"bell_distant_smith",
"child_chasing_chicken",
"rain_barrel_drip",
"cloak_smell_smoke",
"notice_board_torn",
"two_guards_yawn",
}, },
} }
// TownVisitPhraseKey returns e.g. town_visit.merchant.bell_traveler_pack (lineIdx 0..5). // TownVisitPhraseKey returns e.g. town_visit.merchant.bell_traveler_pack (lineIdx 0..n); prefer TownVisitRandomPhraseKey for timed logs.
func TownVisitPhraseKey(npcType string, lineIdx int) string { func TownVisitPhraseKey(npcType string, lineIdx int) string {
slugs, ok := townVisitLineSlugs[npcType] slugs, ok := townVisitLineSlugs[npcType]
keyType := npcType keyType := npcType
@ -90,3 +118,17 @@ func TownVisitPhraseKey(npcType string, lineIdx int) string {
} }
return "town_visit." + keyType + "." + slugs[lineIdx] return "town_visit." + keyType + "." + slugs[lineIdx]
} }
// TownVisitRandomPhraseKey picks a random line for npcType (town NPC visit log).
func TownVisitRandomPhraseKey(npcType string) string {
slugs, ok := townVisitLineSlugs[npcType]
keyType := npcType
if !ok {
slugs = townVisitLineSlugs["generic"]
keyType = "generic"
}
if len(slugs) == 0 {
return ""
}
return "town_visit." + keyType + "." + slugs[rand.Intn(len(slugs))]
}

@ -29,3 +29,12 @@ func TestTownVisitPhraseKeyUsesSlugs(t *testing.T) {
t.Fatalf("unknown type should use generic slugs, got %q", k2) t.Fatalf("unknown type should use generic slugs, got %q", k2)
} }
} }
func TestTownVisitRandomPhraseKeyNonEmpty(t *testing.T) {
for i := 0; i < 20; i++ {
k := TownVisitRandomPhraseKey("merchant")
if k == "" || len(k) < len("town_visit.merchant.") {
t.Fatalf("unexpected key %q", k)
}
}
}

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

@ -63,7 +63,6 @@ type Hero struct {
// Movement state (persisted to DB for reconnect recovery). // Movement state (persisted to DB for reconnect recovery).
CurrentTownID *int64 `json:"currentTownId,omitempty"` CurrentTownID *int64 `json:"currentTownId,omitempty"`
DestinationTownID *int64 `json:"destinationTownId,omitempty"` DestinationTownID *int64 `json:"destinationTownId,omitempty"`
MoveState string `json:"moveState"`
RestKind RestKind `json:"restKind,omitempty"` RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise. // ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"` ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`

@ -0,0 +1,56 @@
package model
import "math/rand"
// 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",
"same_road_twice",
"water_skin_low",
"campfire_smoke_ahead",
"no_coin_no_story",
"armor_pinch_reminder",
"storm_smell_air",
"map_wrong_fold",
"heard_city_bells",
"strap_mended_maybe",
"monster_or_mud",
"share_rations_nod",
"night_cold_early",
"footprints_cross_yours",
"quiet_not_safe",
"merchant_lied_once",
"birds_flew_strange",
}
// HeroMeetAutoPhraseKey returns phrase key by index (legacy tests); prefer RandomHeroMeetAutoPhraseKey for emits.
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
}
// RandomHeroMeetAutoPhraseKey picks a random auto line (offline / scripted hero-meet dialogue).
func RandomHeroMeetAutoPhraseKey() string {
if len(HeroMeetAutoLineSlugs) == 0 {
return "hero_meet.auto.fallback"
}
slug := HeroMeetAutoLineSlugs[rand.Intn(len(HeroMeetAutoLineSlugs))]
return "hero_meet.auto." + slug
}

@ -0,0 +1,12 @@
package model
import "testing"
func TestRandomHeroMeetAutoPhraseKeyNonEmpty(t *testing.T) {
for i := 0; i < 10; i++ {
k := RandomHeroMeetAutoPhraseKey()
if k == "" || len(k) < len("hero_meet.auto.") {
t.Fatalf("unexpected key %q", k)
}
}
}

@ -54,6 +54,21 @@ var RoadsideSlugs = []string{
"smile_nothing_helps", "smile_nothing_helps",
"tomorrow_walk_tonight_breathe", "tomorrow_walk_tonight_breathe",
"grind_volume_down", "grind_volume_down",
"inventory_full_soul",
"checkpoint_tree_suspicious",
"buff_icon_inner_peace",
"rng_prayer_whisper",
"horse_missing_inventory",
"quest_marker_behind_you",
"save_button_reality",
"lag_spirit_anvil",
"npc_repeat_same_line",
"grass_pixel_perfect",
"boss_music_birdsong",
"loot_greed_shame_cycle",
"hp_bar_poetry_slack",
"respawn_thought_comfort",
"roadside_meta_fourth_wall",
} }
// RoadsidePhraseKey returns the full phrase code for a slug suffix. // RoadsidePhraseKey returns the full phrase code for a slug suffix.

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

@ -30,7 +30,7 @@ const heroSelectQuery = `
h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges, h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges,
h.position_x, h.position_y, h.potions, h.position_x, h.position_y, h.potions,
h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops, h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops,
h.current_town_id, h.destination_town_id, h.move_state, h.town_pause, h.current_town_id, h.destination_town_id, h.town_pause,
h.last_online_at, h.changelog_ack_version, h.last_online_at, h.changelog_ack_version,
h.ws_disconnected_at, h.ws_disconnected_at,
h.created_at, h.updated_at h.created_at, h.updated_at
@ -266,10 +266,6 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero.CreatedAt = now hero.CreatedAt = now
hero.UpdatedAt = now hero.UpdatedAt = now
if hero.MoveState == "" {
hero.MoveState = string(model.StateWalking)
}
buffChargesJSON := marshalBuffCharges(hero.BuffCharges) buffChargesJSON := marshalBuffCharges(hero.BuffCharges)
query := ` query := `
@ -285,7 +281,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops, total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops,
last_online_at, last_online_at,
created_at, updated_at, created_at, updated_at,
current_town_id, destination_town_id, move_state current_town_id, destination_town_id
) VALUES ( ) VALUES (
$1, $2, $1, $2,
$3, $4, $5, $6, $7, $3, $4, $5, $6, $7,
@ -298,7 +294,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
$25, $26, $27, $28, $29, $25, $26, $27, $28, $29,
$30, $30,
$31, $32, $31, $32,
$33, $34, $35 $33, $34
) RETURNING id ) RETURNING id
` `
@ -314,7 +310,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops, hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops,
hero.LastOnlineAt, hero.LastOnlineAt,
hero.CreatedAt, hero.UpdatedAt, hero.CreatedAt, hero.UpdatedAt,
hero.CurrentTownID, hero.DestinationTownID, hero.MoveState, hero.CurrentTownID, hero.DestinationTownID,
).Scan(&hero.ID) ).Scan(&hero.ID)
if err != nil { if err != nil {
return fmt.Errorf("insert hero: %w", err) return fmt.Errorf("insert hero: %w", err)
@ -425,7 +421,6 @@ func (s *HeroStore) CreateHeroWithSpawn(ctx context.Context, telegramID int64, n
PositionY: by, PositionY: by,
CurrentTownID: &birth, CurrentTownID: &birth,
DestinationTownID: &dest, DestinationTownID: &dest,
MoveState: string(model.StateWalking),
BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(), BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(),
} }
@ -572,9 +567,8 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
updated_at = $29, updated_at = $29,
destination_town_id = $30, destination_town_id = $30,
current_town_id = $31, current_town_id = $31,
move_state = $32, town_pause = $32
town_pause = $33 WHERE id = $33
WHERE id = $34
` `
townPauseJSON := marshalTownPause(hero.TownPause) townPauseJSON := marshalTownPause(hero.TownPause)
@ -593,7 +587,6 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hero.UpdatedAt, hero.UpdatedAt,
hero.DestinationTownID, hero.DestinationTownID,
hero.CurrentTownID, hero.CurrentTownID,
hero.MoveState,
townPauseJSON, townPauseJSON,
hero.ID, hero.ID,
) )
@ -661,80 +654,16 @@ func (s *HeroStore) ClearWsDisconnectedAt(ctx context.Context, heroID int64) err
return nil return nil
} }
// ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map,
// or heroes resting / in town whose DB row has not been updated recently (offline town timers).
// Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).
func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time.Duration, limit int) ([]*model.Hero, error) {
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
cutoff := time.Now().Add(-offlineThreshold)
query := heroSelectQuery + `
WHERE h.hp > 0 AND h.updated_at < $1
AND (
(h.state = 'walking'
AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting')))
OR h.state IN ('resting', 'in_town')
)
ORDER BY h.updated_at ASC
LIMIT $2
`
rows, err := s.pool.Query(ctx, query, cutoff, limit)
if err != nil {
return nil, fmt.Errorf("list offline heroes: %w", err)
}
defer rows.Close()
var heroes []*model.Hero
for rows.Next() {
h, err := scanHeroFromRows(rows)
if err != nil {
return nil, fmt.Errorf("list offline heroes scan: %w", err)
}
heroes = append(heroes, h)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list offline heroes rows: %w", err)
}
for _, h := range heroes {
if err := s.loadHeroGear(ctx, h); err != nil {
return nil, fmt.Errorf("list offline heroes load gear: %w", err)
}
if err := s.loadHeroInventory(ctx, h); err != nil {
return nil, fmt.Errorf("list offline heroes load inventory: %w", err)
}
}
return heroes, nil
}
// ListHeroesForEngineBootstrap returns heroes that should be loaded into the game engine after a cold start: // ListHeroesForEngineBootstrap returns heroes that should be loaded into the game engine after a cold start:
// session ended (ws_disconnected_at set) and simulatable world state. Limit caps memory use. // session ended (ws_disconnected_at set) and simulatable world state. Limit caps memory use.
func (s *HeroStore) ListHeroesForEngineBootstrap(ctx context.Context, limit int) ([]*model.Hero, error) { func (s *HeroStore) ListHeroesForEngineBootstrap(ctx context.Context) ([]*model.Hero, error) {
if limit <= 0 {
limit = 500
}
if limit > 2000 {
limit = 2000
}
query := heroSelectQuery + ` query := heroSelectQuery + `
WHERE h.hp > 0 AND h.ws_disconnected_at IS NOT NULL WHERE h.hp > 0 AND h.ws_disconnected_at IS NOT NULL
AND (
(h.state = 'walking'
AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting')))
OR h.state IN ('resting', 'in_town', 'fighting')
)
ORDER BY h.updated_at ASC ORDER BY h.updated_at ASC
LIMIT $1
` `
rows, err := s.pool.Query(ctx, query, limit) rows, err := s.pool.Query(ctx, query)
if err != nil { if err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap: %w", err) return nil, fmt.Errorf("list heroes for engine bootstrap: %w", err)
} }
@ -780,7 +709,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
&h.PositionX, &h.PositionY, &h.Potions, &h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt, &h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
@ -815,7 +744,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
&h.PositionX, &h.PositionY, &h.Potions, &h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt, &h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,

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

@ -0,0 +1,2 @@
ALTER TABLE public.heroes
DROP COLUMN IF EXISTS move_state;

@ -0,0 +1,283 @@
-- 20 new towns: 5 inner spiral + 15 outer spiral; roads; NPCs (duplicate merchants / dual quest givers where noted); quests; localization keys.
-- Town order in engine remains ORDER BY level_min from DB — existing towns unchanged.
INSERT INTO public.towns (id, name, name_key, biome, world_x, world_y, radius, level_min, level_max, created_at) VALUES
(12, 'Silverstep', 'town.silverstep.v1', 'meadow', 6172.1, 4478.1, 8, 4, 10, now()),
(13, 'Copperfield', 'town.copperfield.v1', 'forest', 4959.5, 5590.9, 9, 6, 12, now()),
(14, 'Ashford', 'town.ashford.v1', 'ruins', 3526.5, 4781.5, 7, 3, 9, now()),
(15, 'Millbrook', 'town.millbrook.v1', 'meadow', 3853.4, 3168.5, 8, 5, 11, now()),
(16, 'Stonebend', 'town.stonebend.v1', 'canyon', 5488.5, 2981, 10, 8, 14, now()),
(17, 'Highspire', 'town.highspire.v1', 'astral', 10992.3, 4509.9, 18, 40, 46, now()),
(18, 'Saltmere', 'town.saltmere.v1', 'swamp', 10330.9, 7001.7, 16, 38, 44, now()),
(19, 'Ironpost', 'town.ironpost.v1', 'canyon', 8713.1, 9009.1, 17, 42, 48, now()),
(20, 'Greyfen', 'town.greyfen.v1', 'swamp', 6418.8, 10184.9, 15, 35, 41, now()),
(21, 'Dunewatch', 'town.dunewatch.v1', 'meadow', 3844.6, 10325.9, 14, 36, 42, now()),
(22, 'Coldbarrow', 'town.coldbarrow.v1', 'ruins', 1435.5, 9407.7, 14, 33, 39, now()),
(23, 'Mistral', 'town.mistral.v1', 'forest', -391.8, 7589, 13, 31, 37, now()),
(24, 'Hollowmere', 'town.hollowmere.v1', 'swamp', -1321.4, 5184.3, 12, 29, 35, now()),
(25, 'Ashfen', 'town.ashfen.v1', 'volcanic', -1192.5, 2609.5, 12, 27, 33, now()),
(26, 'Thornmere', 'town.thornmere.v1', 'forest', -27.5, 309.6, 11, 25, 31, now()),
(27, 'Windgarde', 'town.windgarde.v1', 'meadow', 1972.2, -1317.6, 11, 23, 29, now()),
(28, 'Frosthollow', 'town.frosthollow.v1', 'ruins', 4460.9, -1990.7, 19, 45, 52, now()),
(29, 'Sungrasp', 'town.sungrasp.v1', 'canyon', 7008.2, -1593.4, 20, 47, 53, now()),
(30, 'Glimmerford', 'town.glimmerford.v1', 'meadow', 9173.7, -194.4, 21, 49, 55, now()),
(31, 'Starveil', 'town.starveil.v1', 'astral', 10582.9, 1964.5, 22, 51, 60, now());
-- Buildings: 12 & 20 have two quest_giver halls + two merchants + healer + dec.
INSERT INTO public.town_buildings (id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at) VALUES
(49, 12, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
(50, 12, 'house.quest_giver', 6.3, -3.5, 'south', 2.5, 2, now()),
(51, 12, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
(52, 12, 'house.merchant', 5.5, 3.2, 'south', 2.5, 2, now()),
(53, 12, 'house.healer', 0, 7.8, 'south', 2.5, 2, now()),
(54, 12, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(55, 12, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now()),
(56, 13, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(57, 13, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(58, 13, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(59, 13, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(60, 13, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(61, 13, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(62, 14, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(63, 14, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(64, 14, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(65, 14, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(66, 14, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(67, 14, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(68, 15, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(69, 15, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(70, 15, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(71, 15, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(72, 15, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(73, 15, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(74, 16, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
(75, 16, 'house.merchant', 7.8, -3.5, 'south', 2.5, 2, now()),
(76, 16, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
(77, 16, 'house.healer', 3, 8.1, 'south', 2.5, 2, now()),
(78, 16, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(79, 16, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now()),
(80, 17, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(81, 17, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(82, 17, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(83, 17, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(84, 17, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(85, 17, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
(86, 18, 'house.quest_giver', -7.2, -4, 'south', 2.5, 2, now()),
(87, 18, 'house.merchant', 8.7, -4, 'south', 2.5, 2, now()),
(88, 18, 'house.merchant', -6, 4, 'south', 2.5, 2, now()),
(89, 18, 'house.healer', 3, 8.4, 'south', 2.5, 2, now()),
(90, 18, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(91, 18, 'decoration.signpost', 0, 10.2, 'south', 0.5, 0.5, now()),
(92, 19, 'house.quest_giver', -7.2, -4, 'south', 2.5, 2, now()),
(93, 19, 'house.merchant', 8.7, -4, 'south', 2.5, 2, now()),
(94, 19, 'house.merchant', -6, 4, 'south', 2.5, 2, now()),
(95, 19, 'house.healer', 3, 8.4, 'south', 2.5, 2, now()),
(96, 19, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(97, 19, 'decoration.signpost', 0, 10.2, 'south', 0.5, 0.5, now()),
(98, 20, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
(99, 20, 'house.quest_giver', 6.3, -3.5, 'south', 2.5, 2, now()),
(100, 20, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
(101, 20, 'house.merchant', 5.5, 3.2, 'south', 2.5, 2, now()),
(102, 20, 'house.healer', 0, 7.8, 'south', 2.5, 2, now()),
(103, 20, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(104, 20, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now());
INSERT INTO public.town_buildings (id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at) VALUES
(105, 21, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(106, 21, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(107, 21, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(108, 21, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(109, 21, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(110, 21, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(111, 22, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(112, 22, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(113, 22, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(114, 22, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(115, 22, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(116, 22, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(117, 23, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(118, 23, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(119, 23, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(120, 23, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(121, 23, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(122, 23, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(123, 24, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(124, 24, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(125, 24, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(126, 24, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(127, 24, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(128, 24, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(129, 25, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(130, 25, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(131, 25, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(132, 25, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(133, 25, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(134, 25, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(135, 26, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(136, 26, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(137, 26, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(138, 26, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(139, 26, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(140, 26, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(141, 27, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(142, 27, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(143, 27, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(144, 27, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(145, 27, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(146, 27, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(147, 28, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(148, 28, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(149, 28, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(150, 28, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(151, 28, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(152, 28, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
(153, 29, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(154, 29, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(155, 29, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(156, 29, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(157, 29, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(158, 29, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
(159, 30, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(160, 30, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(161, 30, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(162, 30, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(163, 30, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(164, 30, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
(165, 31, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(166, 31, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(167, 31, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(168, 31, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(169, 31, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(170, 31, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now());
INSERT INTO public.npcs (id, town_id, name, name_key, type, offset_x, offset_y, created_at, building_id) VALUES
(27, 12, 'Clerk Sera', 'npc.clerk_sera.v1', 'quest_giver', -6.3, -2.3, now(), 49),
(28, 12, 'Notary Bram', 'npc.notary_bram.v1', 'quest_giver', 6.3, -2.3, now(), 50),
(29, 12, 'Copper Nils', 'npc.copper_nils.v1', 'merchant', -5.5, 4.4, now(), 51),
(30, 12, 'Tin Mara', 'npc.tin_mara.v1', 'merchant', 5.5, 4.4, now(), 52),
(31, 12, 'Sister Calm', 'npc.sister_calm.v1', 'healer', 0, 8.7, now(), 53),
(32, 13, 'Foreman Rook', 'npc.foreman_rook.v1', 'quest_giver', -4.8, -2.3, now(), 56),
(33, 13, 'Wire Merchant', 'npc.wire_merchant.v1', 'merchant', 6.3, -2.3, now(), 57),
(34, 13, 'Bolt Jada', 'npc.bolt_jada.v1', 'merchant', -4.5, 4.4, now(), 58),
(35, 13, 'Sage Mottle', 'npc.sage_mottle.v1', 'healer', 3, 8.1, now(), 59),
(36, 14, 'Warden Pike', 'npc.warden_pike.v1', 'quest_giver', -4.8, -2.3, now(), 62),
(37, 14, 'Ash Vendor', 'npc.ash_vendor.v1', 'merchant', 6.3, -2.3, now(), 63),
(38, 14, 'Scrap Yori', 'npc.scrap_yori.v1', 'merchant', -4.5, 4.4, now(), 64),
(39, 14, 'Herb Rill', 'npc.herb_rill.v1', 'healer', 3, 8.1, now(), 65),
(40, 15, 'Miller Tove', 'npc.miller_tove.v1', 'quest_giver', -4.8, -2.3, now(), 68),
(41, 15, 'Grain Peddler', 'npc.grain_peddler.v1', 'merchant', 6.3, -2.3, now(), 69),
(42, 15, 'Sack Ren', 'npc.sack_ren.v1', 'merchant', -4.5, 4.4, now(), 70),
(43, 15, 'Brother Salve', 'npc.brother_salve.v1', 'healer', 3, 8.1, now(), 71),
(44, 16, 'Stone Judge', 'npc.stone_judge.v1', 'quest_giver', -6.3, -2.3, now(), 74),
(45, 16, 'Edge Trader', 'npc.edge_trader.v1', 'merchant', 7.8, -2.3, now(), 75),
(46, 16, 'Crack Merchant', 'npc.crack_merchant.v1', 'merchant', -5.5, 4.4, now(), 76),
(47, 16, 'Sister Flint', 'npc.sister_flint.v1', 'healer', 3, 9.0, now(), 77),
(48, 17, 'Starward Oren', 'npc.starward_oren.v1', 'quest_giver', -8.1, -3.3, now(), 80),
(49, 17, 'Spire Imports', 'npc.spire_imports.v1', 'merchant', 9.6, -3.3, now(), 81),
(50, 17, 'Comet Outfitter', 'npc.comet_outfitter.v1', 'merchant', -7, 5.7, now(), 82),
(51, 17, 'Void Medic', 'npc.void_medic.v1', 'healer', 3, 10.2, now(), 83),
(52, 18, 'Brine Archivist', 'npc.brine_archivist.v1', 'quest_giver', -7.2, -2.8, now(), 86),
(53, 18, 'Salt Broker', 'npc.salt_broker.v1', 'merchant', 8.7, -2.8, now(), 87),
(54, 18, 'Reed Trader', 'npc.reed_trader.v1', 'merchant', -6, 5.2, now(), 88),
(55, 18, 'Mud Healer', 'npc.mud_healer.v1', 'healer', 3, 9.0, now(), 89),
(56, 19, 'Post Warden', 'npc.post_warden.v1', 'quest_giver', -7.2, -2.8, now(), 92),
(57, 19, 'Ironmonger', 'npc.ironmonger.v1', 'merchant', 8.7, -2.8, now(), 93),
(58, 19, 'Rivet Seller', 'npc.rivet_seller.v1', 'merchant', -6, 5.2, now(), 94),
(59, 19, 'Forge Medic', 'npc.forge_medic.v1', 'healer', 3, 9.0, now(), 95),
(60, 20, 'Bog Chronicler', 'npc.bog_chronicler.v1', 'quest_giver', -6.3, -2.3, now(), 98),
(61, 20, 'Fen Notary', 'npc.fen_notary.v1', 'quest_giver', 6.3, -2.3, now(), 99),
(62, 20, 'Mire Merchant', 'npc.mire_merchant.v1', 'merchant', -5.5, 4.4, now(), 100),
(63, 20, 'Reed Coin', 'npc.reed_coin.v1', 'merchant', 5.5, 4.4, now(), 101),
(64, 20, 'Swamp Mender', 'npc.swamp_mender.v1', 'healer', 0, 8.7, now(), 102),
(65, 21, 'Dune Scout', 'npc.dune_scout.v1', 'quest_giver', -4.8, -2.3, now(), 105),
(66, 21, 'Silt Trader', 'npc.silt_trader.v1', 'merchant', 6.3, -2.3, now(), 106),
(67, 21, 'Sand Peddler', 'npc.sand_peddler.v1', 'merchant', -4.5, 4.4, now(), 107),
(68, 21, 'Grit Healer', 'npc.grit_healer.v1', 'healer', 3, 8.1, now(), 108),
(69, 22, 'Barrow Keeper', 'npc.barrow_keeper.v1', 'quest_giver', -4.8, -2.3, now(), 111),
(70, 22, 'Bone Outfitter', 'npc.bone_outfitter.v1', 'merchant', 6.3, -2.3, now(), 112),
(71, 22, 'Cold Peddler', 'npc.cold_peddler.v1', 'merchant', -4.5, 4.4, now(), 113),
(72, 22, 'Shroud Medic', 'npc.shroud_medic.v1', 'healer', 3, 8.1, now(), 114),
(73, 23, 'Mist Ranger', 'npc.mist_ranger.v1', 'quest_giver', -4.8, -2.3, now(), 117),
(74, 23, 'Fog Trader', 'npc.fog_trader.v1', 'merchant', 6.3, -2.3, now(), 118),
(75, 23, 'Dew Merchant', 'npc.dew_merchant.v1', 'merchant', -4.5, 4.4, now(), 119),
(76, 23, 'Vapor Healer', 'npc.vapor_healer.v1', 'healer', 3, 8.1, now(), 120),
(77, 24, 'Hollow Scribe', 'npc.hollow_scribe.v1', 'quest_giver', -4.8, -2.3, now(), 123),
(78, 24, 'Mer Imports', 'npc.mer_imports.v1', 'merchant', 6.3, -2.3, now(), 124),
(79, 24, 'Rot Trader', 'npc.rot_trader.v1', 'merchant', -4.5, 4.4, now(), 125),
(80, 24, 'Bog Medic', 'npc.bog_medic.v1', 'healer', 3, 8.1, now(), 126),
(81, 25, 'Ash Herald', 'npc.herald_ash.v1', 'quest_giver', -4.8, -2.3, now(), 129),
(82, 25, 'Cinder Seller', 'npc.cinder_seller.v1', 'merchant', 6.3, -2.3, now(), 130),
(83, 25, 'Ember Peddler', 'npc.ember_peddler.v1', 'merchant', -4.5, 4.4, now(), 131),
(84, 25, 'Ash Healer', 'npc.ash_healer.v1', 'healer', 3, 8.1, now(), 132),
(85, 26, 'Thorn Watcher', 'npc.thorn_watcher.v1', 'quest_giver', -4.8, -2.3, now(), 135),
(86, 26, 'Briar Trader', 'npc.briar_trader.v1', 'merchant', 6.3, -2.3, now(), 136),
(87, 26, 'Root Seller', 'npc.root_seller.v1', 'merchant', -4.5, 4.4, now(), 137),
(88, 26, 'Leaf Medic', 'npc.leaf_medic.v1', 'healer', 3, 8.1, now(), 138),
(89, 27, 'Gale Factor', 'npc.gale_factor.v1', 'quest_giver', -4.8, -2.3, now(), 141),
(90, 27, 'Wind Outfitter', 'npc.wind_outfitter.v1', 'merchant', 6.3, -2.3, now(), 142),
(91, 27, 'Gust Peddler', 'npc.gust_peddler.v1', 'merchant', -4.5, 4.4, now(), 143),
(92, 27, 'Breeze Healer', 'npc.breeze_healer.v1', 'healer', 3, 8.1, now(), 144),
(93, 28, 'Frost Archivist', 'npc.frost_archivist.v1', 'quest_giver', -8.1, -3.3, now(), 147),
(94, 28, 'Rime Trader', 'npc.rime_trader.v1', 'merchant', 9.6, -3.3, now(), 148),
(95, 28, 'Hoarfrost Seller', 'npc.hoarfrost_seller.v1', 'merchant', -7, 5.7, now(), 149),
(96, 28, 'Ice Medic', 'npc.ice_medic.v1', 'healer', 3, 10.2, now(), 150),
(97, 29, 'Sun Warden', 'npc.sun_warden.v1', 'quest_giver', -8.1, -3.3, now(), 153),
(98, 29, 'Cliff Merchant', 'npc.cliff_merchant.v1', 'merchant', 9.6, -3.3, now(), 154),
(99, 29, 'Crag Peddler', 'npc.crag_peddler.v1', 'merchant', -7, 5.7, now(), 155),
(100, 29, 'Dust Healer', 'npc.dust_healer.v1', 'healer', 3, 10.2, now(), 156),
(101, 30, 'Ford Marshal', 'npc.ford_marshal.v1', 'quest_giver', -8.1, -3.3, now(), 159),
(102, 30, 'River Trader', 'npc.river_trader.v1', 'merchant', 9.6, -3.3, now(), 160),
(103, 30, 'Bridge Seller', 'npc.bridge_seller.v1', 'merchant', -7, 5.7, now(), 161),
(104, 30, 'Stream Medic', 'npc.stream_medic.v1', 'healer', 3, 10.2, now(), 162),
(105, 31, 'Veil Seer', 'npc.veil_seer.v1', 'quest_giver', -8.1, -3.3, now(), 165),
(106, 31, 'Star Trader', 'npc.star_trader.v1', 'merchant', 9.6, -3.3, now(), 166),
(107, 31, 'Nebula Peddler', 'npc.nebula_peddler.v1', 'merchant', -7, 5.7, now(), 167),
(108, 31, 'Veil Mender', 'npc.veil_mender_starveil.v1', 'healer', 3, 10.2, now(), 168);
-- Directed roads (pairwise both ways). Distance is recomputed at load from waypoints.
INSERT INTO public.roads (id, from_town_id, to_town_id, distance) VALUES
(51, 12, 4, 1000), (52, 4, 12, 1000), (53, 12, 10, 1000), (54, 10, 12, 1000), (55, 12, 13, 1000), (56, 13, 12, 1000),
(57, 13, 11, 1000), (58, 11, 13, 1000), (59, 13, 5, 1000), (60, 5, 13, 1000), (61, 13, 14, 1000), (62, 14, 13, 1000),
(63, 14, 5, 1000), (64, 5, 14, 1000), (65, 14, 6, 1000), (66, 6, 14, 1000), (67, 14, 15, 1000), (68, 15, 14, 1000),
(69, 15, 5, 1000), (70, 5, 15, 1000), (71, 15, 16, 1000), (72, 16, 15, 1000), (73, 16, 1, 1000), (74, 1, 16, 1000),
(75, 16, 8, 1000), (76, 8, 16, 1000), (77, 16, 12, 1000), (78, 12, 16, 1000),
(79, 17, 18, 1000), (80, 18, 17, 1000), (81, 18, 19, 1000), (82, 19, 18, 1000), (83, 19, 20, 1000), (84, 20, 19, 1000),
(85, 20, 21, 1000), (86, 21, 20, 1000), (87, 21, 22, 1000), (88, 22, 21, 1000), (89, 22, 23, 1000), (90, 23, 22, 1000),
(91, 23, 24, 1000), (92, 24, 23, 1000), (93, 24, 25, 1000), (94, 25, 24, 1000), (95, 25, 26, 1000), (96, 26, 25, 1000),
(97, 26, 27, 1000), (98, 27, 26, 1000), (99, 27, 28, 1000), (100, 28, 27, 1000), (101, 28, 29, 1000), (102, 29, 28, 1000),
(103, 29, 30, 1000), (104, 30, 29, 1000), (105, 30, 31, 1000), (106, 31, 30, 1000), (107, 31, 17, 1000), (108, 17, 31, 1000),
(109, 17, 3, 1000), (110, 3, 17, 1000), (111, 18, 10, 1000), (112, 10, 18, 1000), (113, 19, 4, 1000), (114, 4, 19, 1000),
(115, 20, 11, 1000), (116, 11, 20, 1000), (117, 21, 11, 1000), (118, 11, 21, 1000), (119, 22, 5, 1000), (120, 5, 22, 1000),
(121, 23, 6, 1000), (122, 6, 23, 1000), (123, 24, 6, 1000), (124, 6, 24, 1000), (125, 25, 7, 1000), (126, 7, 25, 1000),
(127, 26, 7, 1000), (128, 7, 26, 1000), (129, 27, 7, 1000), (130, 7, 27, 1000), (131, 28, 1, 1000), (132, 1, 28, 1000),
(133, 29, 1, 1000), (134, 1, 29, 1000), (135, 30, 1, 1000), (136, 1, 30, 1000), (137, 31, 2, 1000), (138, 2, 31, 1000);
INSERT INTO public.quests (npc_id, quest_key, title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES
(27, 'quest.silverstep_bandits.v1', 'Bandit Echo', 'Thin bandit packs along the inner roads.', 'kill_count', 5, NULL, 'bandit', NULL, 0, 4, 10, 90, 48, 0),
(28, 'quest.silverstep_visit_mossharbor.v1', 'Letter to Mossharbor', 'Carry a sealed note to Harbor-ward Lissa.', 'visit_town', 1, NULL, NULL, 8, 0, 4, 10, 55, 30, 0),
(32, 'quest.copperfield_wolves.v1', 'Copperfield Wolves', 'Wolves circle the smelting sheds.', 'kill_count', 6, NULL, 'wolf', NULL, 0, 6, 12, 120, 62, 0),
(36, 'quest.ashford_skeletons.v1', 'Ashford Bones', 'Risen bones worry the ruins lane.', 'kill_count', 7, NULL, 'skeleton', NULL, 0, 3, 9, 85, 44, 0),
(40, 'quest.millbrook_boars.v1', 'Millbrook Boars', 'Boars ruin the grain path.', 'kill_count', 6, NULL, 'boar', NULL, 0, 5, 11, 100, 52, 0),
(44, 'quest.stonebend_orcs.v1', 'Stonebend Orcs', 'Orc scouts press the canyon shelf.', 'kill_count', 8, NULL, 'orc', NULL, 0, 8, 14, 140, 75, 1),
(48, 'quest.highspire_shades.v1', 'Shade at the Spire', 'Shades cling to the high astral road.', 'kill_count', 6, NULL, 'shade', NULL, 0, 40, 46, 520, 300, 2),
(52, 'quest.saltmere_spiders.v1', 'Saltmere Silk', 'Spiders infest the brine posts.', 'kill_count', 8, NULL, 'spider', NULL, 0, 38, 44, 480, 280, 1),
(56, 'quest.ironpost_golems.v1', 'Ironpost Sentinels', 'Golems block the iron road.', 'kill_count', 5, NULL, 'golem', NULL, 0, 42, 48, 560, 320, 2),
(60, 'quest.greyfen_harpies.v1', 'Greyfen Harpies', 'Harpies pick at the fen docks.', 'kill_count', 7, NULL, 'harpy', NULL, 0, 35, 41, 420, 240, 1),
(61, 'quest.greyfen_visit_duskwatch.v1', 'Warning to Duskwatch', 'Bring tidings to Sister Morah.', 'visit_town', 1, NULL, NULL, 11, 0, 35, 41, 200, 110, 0),
(65, 'quest.dunewatch_zombies.v1', 'Dune Dead', 'Zombies wander the silt flats.', 'kill_count', 10, NULL, 'zombie', NULL, 0, 36, 42, 440, 250, 1),
(69, 'quest.coldbarrow_wraiths.v1', 'Coldbarrow Wraiths', 'Wraiths drift between the barrows.', 'kill_count', 8, NULL, 'wraith', NULL, 0, 33, 39, 400, 230, 1),
(73, 'quest.mistral_cultists.v1', 'Mistral Cultists', 'Cultists chant in the fog line.', 'kill_count', 9, NULL, 'cultist', NULL, 0, 31, 37, 380, 220, 1),
(77, 'quest.hollowmere_treants.v1', 'Hollowmere Roots', 'Treants root in the hollow mere.', 'kill_count', 4, NULL, 'treant', NULL, 0, 29, 35, 360, 210, 1),
(81, 'quest.ashfen_demons.v1', 'Ashfen Embers', 'Demons leave cinders on the ash fen.', 'kill_count', 5, NULL, 'demon', NULL, 0, 27, 33, 340, 200, 1),
(85, 'quest.thornmere_lizards.v1', 'Thornmere Scalebacks', 'Battle lizards bask by the thorns.', 'kill_count', 8, NULL, 'battle_lizard', NULL, 0, 25, 31, 320, 190, 1),
(89, 'quest.windgarde_visit_willowdale.v1', 'Parcel for Willowdale', 'Deliver a parcel to Elder Maren.', 'visit_town', 1, NULL, NULL, 1, 0, 23, 29, 150, 85, 0),
(93, 'quest.frosthollow_titans.v1', 'Frost Titan Steps', 'Titans loom past the frost hollow.', 'kill_count', 4, NULL, 'titan', NULL, 0, 45, 52, 640, 380, 2),
(97, 'quest.sungrasp_wyverns.v1', 'Sungrasp Wyverns', 'Wyverns circle the sun cliffs.', 'kill_count', 6, NULL, 'wyvern', NULL, 0, 47, 53, 680, 400, 2),
(101, 'quest.glimmerford_manticores.v1', 'Glimmerford Alphas', 'Manticores claim the ford approaches.', 'kill_count', 5, NULL, 'manticore', NULL, 0, 49, 55, 720, 420, 2),
(105, 'quest.starveil_wardens.v1', 'Veil Wardens', 'Forest wardens dispute the star road.', 'kill_count', 3, NULL, 'forest_warden', NULL, 0, 51, 60, 800, 480, 3);
SELECT pg_catalog.setval('public.towns_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.towns), true);
SELECT pg_catalog.setval('public.town_buildings_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.town_buildings), true);
SELECT pg_catalog.setval('public.npcs_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.npcs), true);
SELECT pg_catalog.setval('public.roads_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.roads), true);
SELECT pg_catalog.setval('public.quests_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.quests), true);

@ -0,0 +1,29 @@
-- Scale world positions so every road segment (straight line between town centers)
-- is at least (1.2 world_units/sec) * (1.5 * 3600 sec) = 6480 units.
-- Method: uniform scale from centroid of towns 1..31; factor = 6480 / min_edge
-- where min_edge was ~870.54 (towns 82) before this migration.
-- Server recomputes road polylines and Distance in LoadRoadGraph from town coords.
-- Centroid and scale (derived from pre-migration layout including 000030 towns).
-- cx, cy = AVG(world_x), AVG(world_y) over ids 1..31 before scale.
DO $$
DECLARE
cx double precision := 5365.609677419355;
cy double precision := 4259.612903225806;
s double precision := 6480.0 / 870.5435801842434;
BEGIN
UPDATE public.towns
SET world_x = cx + (world_x - cx) * s,
world_y = cy + (world_y - cy) * s
WHERE id BETWEEN 1 AND 31;
UPDATE public.heroes
SET position_x = cx + (position_x - cx) * s,
position_y = cy + (position_y - cy) * s;
END $$;
-- Stored road.distance is overwritten at runtime; scale placeholders for any SQL/reporting.
UPDATE public.roads SET distance = distance * (6480.0 / 870.5435801842434);
-- Legacy table not read by Go server; remove stale geometry so DB matches new world.
TRUNCATE TABLE public.road_waypoints;

@ -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.
*/ */

@ -20,6 +20,26 @@ export const TOWN_ID_TO_NAME_KEY: Record<number, string> = {
9: 'town.emberwell.v1', 9: 'town.emberwell.v1',
10: 'town.frostmark.v1', 10: 'town.frostmark.v1',
11: 'town.duskwatch.v1', 11: 'town.duskwatch.v1',
12: 'town.silverstep.v1',
13: 'town.copperfield.v1',
14: 'town.ashford.v1',
15: 'town.millbrook.v1',
16: 'town.stonebend.v1',
17: 'town.highspire.v1',
18: 'town.saltmere.v1',
19: 'town.ironpost.v1',
20: 'town.greyfen.v1',
21: 'town.dunewatch.v1',
22: 'town.coldbarrow.v1',
23: 'town.mistral.v1',
24: 'town.hollowmere.v1',
25: 'town.ashfen.v1',
26: 'town.thornmere.v1',
27: 'town.windgarde.v1',
28: 'town.frosthollow.v1',
29: 'town.sungrasp.v1',
30: 'town.glimmerford.v1',
31: 'town.starveil.v1',
}; };
/** Localized town label from numeric `towns.id` (visit_town quest target, etc.). */ /** Localized town label from numeric `towns.id` (visit_town quest target, etc.). */
@ -46,6 +66,26 @@ const TOWNS: Record<string, Bilingual> = {
'town.emberwell.v1': { en: 'Emberwell', ru: 'Эмбервелл' }, 'town.emberwell.v1': { en: 'Emberwell', ru: 'Эмбервелл' },
'town.frostmark.v1': { en: 'Frostmark', ru: 'Фростмарк' }, 'town.frostmark.v1': { en: 'Frostmark', ru: 'Фростмарк' },
'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Дасквотч' }, 'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Дасквотч' },
'town.silverstep.v1': { en: 'Silverstep', ru: 'Сильверстеп' },
'town.copperfield.v1': { en: 'Copperfield', ru: 'Копперфилд' },
'town.ashford.v1': { en: 'Ashford', ru: 'Эшфорд' },
'town.millbrook.v1': { en: 'Millbrook', ru: 'Милбрук' },
'town.stonebend.v1': { en: 'Stonebend', ru: 'Стоунбенд' },
'town.highspire.v1': { en: 'Highspire', ru: 'Хайспайр' },
'town.saltmere.v1': { en: 'Saltmere', ru: 'Солтмиер' },
'town.ironpost.v1': { en: 'Ironpost', ru: 'Айронпост' },
'town.greyfen.v1': { en: 'Greyfen', ru: 'Грейфен' },
'town.dunewatch.v1': { en: 'Dunewatch', ru: 'Дьюнвотч' },
'town.coldbarrow.v1': { en: 'Coldbarrow', ru: 'Колдбарроу' },
'town.mistral.v1': { en: 'Mistral', ru: 'Мистраль' },
'town.hollowmere.v1': { en: 'Hollowmere', ru: 'Холлоумир' },
'town.ashfen.v1': { en: 'Ashfen', ru: 'Эшфен' },
'town.thornmere.v1': { en: 'Thornmere', ru: 'Торнмир' },
'town.windgarde.v1': { en: 'Windgarde', ru: 'Виндгард' },
'town.frosthollow.v1': { en: 'Frosthollow', ru: 'Фростхоллоу' },
'town.sungrasp.v1': { en: 'Sungrasp', ru: 'Санграсп' },
'town.glimmerford.v1': { en: 'Glimmerford', ru: 'Глиммерфорд' },
'town.starveil.v1': { en: 'Starveil', ru: 'Старвейл' },
}; };
const NPCS: Record<string, Bilingual> = { const NPCS: Record<string, Bilingual> = {
@ -58,6 +98,88 @@ const NPCS: Record<string, Bilingual> = {
'npc.bone_merchant.v1': { en: 'Bone Merchant', ru: 'Торговец костями' }, 'npc.bone_merchant.v1': { en: 'Bone Merchant', ru: 'Торговец костями' },
'npc.priestess_liora.v1': { en: 'Priestess Liora', ru: 'Жрица Лиора' }, 'npc.priestess_liora.v1': { en: 'Priestess Liora', ru: 'Жрица Лиора' },
[WANDERING_MERCHANT_NPC_KEY]: { en: 'Wandering Merchant', ru: 'Бродячий торговец' }, [WANDERING_MERCHANT_NPC_KEY]: { en: 'Wandering Merchant', ru: 'Бродячий торговец' },
'npc.clerk_sera.v1': { en: 'Clerk Sera', ru: 'Клер Сера' },
'npc.notary_bram.v1': { en: 'Notary Bram', ru: 'Нотариус Брам' },
'npc.copper_nils.v1': { en: 'Copper Nils', ru: 'Коппер Нилс' },
'npc.tin_mara.v1': { en: 'Tin Mara', ru: 'Тин Мара' },
'npc.sister_calm.v1': { en: 'Sister Calm', ru: 'Сестра Калм' },
'npc.foreman_rook.v1': { en: 'Foreman Rook', ru: 'Форман Рук' },
'npc.wire_merchant.v1': { en: 'Wire Merchant', ru: 'Торговец проволокой' },
'npc.bolt_jada.v1': { en: 'Bolt Jada', ru: 'Болт Джада' },
'npc.sage_mottle.v1': { en: 'Sage Mottle', ru: 'Сейдж Моттл' },
'npc.warden_pike.v1': { en: 'Warden Pike', ru: 'Варден Пайк' },
'npc.ash_vendor.v1': { en: 'Ash Vendor', ru: 'Торговец золой' },
'npc.scrap_yori.v1': { en: 'Scrap Yori', ru: 'Скрап Йори' },
'npc.herb_rill.v1': { en: 'Herb Rill', ru: 'Херб Рилл' },
'npc.miller_tove.v1': { en: 'Miller Tove', ru: 'Миллер Тов' },
'npc.grain_peddler.v1': { en: 'Grain Peddler', ru: 'Зерновой бродяга' },
'npc.sack_ren.v1': { en: 'Sack Ren', ru: 'Сак Рен' },
'npc.brother_salve.v1': { en: 'Brother Salve', ru: 'Брат Сальв' },
'npc.stone_judge.v1': { en: 'Stone Judge', ru: 'Каменный судья' },
'npc.edge_trader.v1': { en: 'Edge Trader', ru: 'Торговец с краю' },
'npc.crack_merchant.v1': { en: 'Crack Merchant', ru: 'Торговец из трещины' },
'npc.sister_flint.v1': { en: 'Sister Flint', ru: 'Сестра Флинт' },
'npc.starward_oren.v1': { en: 'Starward Oren', ru: 'Старворд Орен' },
'npc.spire_imports.v1': { en: 'Spire Imports', ru: 'Спайр Импортс' },
'npc.comet_outfitter.v1': { en: 'Comet Outfitter', ru: 'Комет Аутфиттер' },
'npc.void_medic.v1': { en: 'Void Medic', ru: 'Медик пустоты' },
'npc.brine_archivist.v1': { en: 'Brine Archivist', ru: 'Архивариус рассола' },
'npc.salt_broker.v1': { en: 'Salt Broker', ru: 'Солёный брокер' },
'npc.reed_trader.v1': { en: 'Reed Trader', ru: 'Торговец тростником' },
'npc.mud_healer.v1': { en: 'Mud Healer', ru: 'Грязевой лекарь' },
'npc.post_warden.v1': { en: 'Post Warden', ru: 'Страж поста' },
'npc.ironmonger.v1': { en: 'Ironmonger', ru: 'Железный торговец' },
'npc.rivet_seller.v1': { en: 'Rivet Seller', ru: 'Продавец заклёпок' },
'npc.forge_medic.v1': { en: 'Forge Medic', ru: 'Кузнечный медик' },
'npc.bog_chronicler.v1': { en: 'Bog Chronicler', ru: 'Хронист болота' },
'npc.fen_notary.v1': { en: 'Fen Notary', ru: 'Нотариус топи' },
'npc.mire_merchant.v1': { en: 'Mire Merchant', ru: 'Торговец трясиной' },
'npc.reed_coin.v1': { en: 'Reed Coin', ru: 'Рид Коин' },
'npc.swamp_mender.v1': { en: 'Swamp Mender', ru: 'Болотный латальщик' },
'npc.dune_scout.v1': { en: 'Dune Scout', ru: 'Разведчик дюн' },
'npc.silt_trader.v1': { en: 'Silt Trader', ru: 'Торговец илом' },
'npc.sand_peddler.v1': { en: 'Sand Peddler', ru: 'Песочный бродяга' },
'npc.grit_healer.v1': { en: 'Grit Healer', ru: 'Грит‑лекарь' },
'npc.barrow_keeper.v1': { en: 'Barrow Keeper', ru: 'Хранитель курганов' },
'npc.bone_outfitter.v1': { en: 'Bone Outfitter', ru: 'Костяной снаряженец' },
'npc.cold_peddler.v1': { en: 'Cold Peddler', ru: 'Холодный бродяга' },
'npc.shroud_medic.v1': { en: 'Shroud Medic', ru: 'Медик покрова' },
'npc.mist_ranger.v1': { en: 'Mist Ranger', ru: 'Рейнджер тумана' },
'npc.fog_trader.v1': { en: 'Fog Trader', ru: 'Торговец туманом' },
'npc.dew_merchant.v1': { en: 'Dew Merchant', ru: 'Торговец росой' },
'npc.vapor_healer.v1': { en: 'Vapor Healer', ru: 'Лекарь пара' },
'npc.hollow_scribe.v1': { en: 'Hollow Scribe', ru: 'Писарь пустоты' },
'npc.mer_imports.v1': { en: 'Mer Imports', ru: 'Мер Импортс' },
'npc.rot_trader.v1': { en: 'Rot Trader', ru: 'Торговец гнилью' },
'npc.bog_medic.v1': { en: 'Bog Medic', ru: 'Болотный медик' },
'npc.herald_ash.v1': { en: 'Ash Herald', ru: 'Глашатай пепла' },
'npc.cinder_seller.v1': { en: 'Cinder Seller', ru: 'Продавец золы' },
'npc.ember_peddler.v1': { en: 'Ember Peddler', ru: 'Угольный бродяга' },
'npc.ash_healer.v1': { en: 'Ash Healer', ru: 'Пепельный лекарь' },
'npc.thorn_watcher.v1': { en: 'Thorn Watcher', ru: 'Дозорный шипов' },
'npc.briar_trader.v1': { en: 'Briar Trader', ru: 'Торговец шипами' },
'npc.root_seller.v1': { en: 'Root Seller', ru: 'Продавец корней' },
'npc.leaf_medic.v1': { en: 'Leaf Medic', ru: 'Лиственный медик' },
'npc.gale_factor.v1': { en: 'Gale Factor', ru: 'Фактор шторма' },
'npc.wind_outfitter.v1': { en: 'Wind Outfitter', ru: 'Ветряной снаряженец' },
'npc.gust_peddler.v1': { en: 'Gust Peddler', ru: 'Порывистый бродяга' },
'npc.breeze_healer.v1': { en: 'Breeze Healer', ru: 'Лекарь бриза' },
'npc.frost_archivist.v1': { en: 'Frost Archivist', ru: 'Морозный архивариус' },
'npc.rime_trader.v1': { en: 'Rime Trader', ru: 'Торговец инеем' },
'npc.hoarfrost_seller.v1': { en: 'Hoarfrost Seller', ru: 'Продавец инея' },
'npc.ice_medic.v1': { en: 'Ice Medic', ru: 'Лёд‑медик' },
'npc.sun_warden.v1': { en: 'Sun Warden', ru: 'Страж солнца' },
'npc.cliff_merchant.v1': { en: 'Cliff Merchant', ru: 'Утёсный торговец' },
'npc.crag_peddler.v1': { en: 'Crag Peddler', ru: 'Краг‑бродяга' },
'npc.dust_healer.v1': { en: 'Dust Healer', ru: 'Пыльный лекарь' },
'npc.ford_marshal.v1': { en: 'Ford Marshal', ru: 'Маршал брода' },
'npc.river_trader.v1': { en: 'River Trader', ru: 'Речной торговец' },
'npc.bridge_seller.v1': { en: 'Bridge Seller', ru: 'Продавец мостов' },
'npc.stream_medic.v1': { en: 'Stream Medic', ru: 'Ручьевой медик' },
'npc.veil_seer.v1': { en: 'Veil Seer', ru: 'Видящая завесы' },
'npc.star_trader.v1': { en: 'Star Trader', ru: 'Звёздный торговец' },
'npc.nebula_peddler.v1': { en: 'Nebula Peddler', ru: 'Туманность‑бродяга' },
'npc.veil_mender_starveil.v1': { en: 'Veil Mender', ru: 'Латальщик завесы' },
}; };
const DIALOGUES: Record<string, Bilingual> = { const DIALOGUES: Record<string, Bilingual> = {

@ -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,35 @@ 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.
hero_meet.auto.same_road_twice: Feels like we walked this bend twice.
hero_meet.auto.water_skin_low: Water's low in the skin — next well matters.
hero_meet.auto.campfire_smoke_ahead: Smoke ahead — maybe a safe fire.
hero_meet.auto.no_coin_no_story: No coin, no story — that's the rule.
hero_meet.auto.armor_pinch_reminder: Armor pinches; you tighten it anyway.
hero_meet.auto.storm_smell_air: Storm smell on the air — or just your imagination.
hero_meet.auto.map_wrong_fold: Your map has the wrong fold; you pretend it's fine.
hero_meet.auto.heard_city_bells: Thought I heard city bells — probably birds.
hero_meet.auto.strap_mended_maybe: Strap's held so far; 'mended' is a strong word.
hero_meet.auto.monster_or_mud: Could be a monster — could be mud. Same heartbeat.
hero_meet.auto.share_rations_nod: If you share rations, we nod and call it peace.
hero_meet.auto.night_cold_early: Night feels early today; the road disagrees.
hero_meet.auto.footprints_cross_yours: Footprints cross yours — none of your business.
hero_meet.auto.quiet_not_safe: Quiet doesn't mean safe; it means listening.
hero_meet.auto.merchant_lied_once: A merchant lied to me once. I bought soup anyway.
hero_meet.auto.birds_flew_strange: Birds flew strange yesterday. Today they're normal. Suspicious.
achievements: achievements:
first_blood: First Blood first_blood: First Blood
@ -508,6 +542,21 @@ roadside:
smile_nothing_helps: You smile at nothing in particular. It helps. smile_nothing_helps: You smile at nothing in particular. It helps.
tomorrow_walk_tonight_breathe: Tomorrow you'll walk again. Tonight you just breathe. tomorrow_walk_tonight_breathe: Tomorrow you'll walk again. Tonight you just breathe.
grind_volume_down: You admit the grind is loud, then turn the volume down. grind_volume_down: You admit the grind is loud, then turn the volume down.
inventory_full_soul: If your soul had inventory slots, regret would be legendary.
checkpoint_tree_suspicious: That tree looks like a checkpoint. It refuses to save.
buff_icon_inner_peace: You search for a buff icon for inner peace. Not lootable.
rng_prayer_whisper: You whisper a small prayer to RNG. It answers with wind.
horse_missing_inventory: Your imaginary horse is missing from inventory. Tragic.
quest_marker_behind_you: The quest marker is probably behind you. Classic.
save_button_reality: You wish reality had a save button. It has dirt instead.
lag_spirit_anvil: Your spirit lags one beat behind your body. Anvil timing.
npc_repeat_same_line: You suspect NPCs rehearse the same line in every timeline.
grass_pixel_perfect: The grass is insultingly pretty. Pixel-perfect shame.
boss_music_birdsong: No boss music — only birds. Somehow worse.
loot_greed_shame_cycle: Loot thought, then greed, then shame. Full combo.
hp_bar_poetry_slack: Your HP bar is poetry written in slack.
respawn_thought_comfort: Respawn isn't real, but the thought is warm.
roadside_meta_fourth_wall: Even the roadside thinks the fourth wall is drafty.
town_npc_visit: town_npc_visit:
merchant: merchant:
@ -517,6 +566,12 @@ town_npc_visit:
rumors_bandits_carts: You swap rumors about bandits and broken cart wheels. rumors_bandits_carts: You swap rumors about bandits and broken cart wheels.
bell_traveler_pack: A bell tinkles as another traveler shoulders their pack. bell_traveler_pack: A bell tinkles as another traveler shoulders their pack.
step_back_tally_gold: You step back, mentally tallying what you can afford. step_back_tally_gold: You step back, mentally tallying what you can afford.
scale_dust_counter: Dust on the scales makes every coin an argument.
rope_coil_trips_you: A coil of rope tries to trip you — friendly sabotage.
copper_jingles_pouch: Copper jingles like it owns the pouch.
foreign_coin_bite: You test a foreign coin with your teeth. Old habit.
no_credit_today: '''No credit today'' is carved into the counter like scripture.'
closing_soon_maybe: They mutter 'closing soon' with zero conviction.
healer: healer:
linens_herbs_tent: Clean linens and sharp herbs fill the small tent. linens_herbs_tent: Clean linens and sharp herbs fill the small tent.
professional_frown_onceover: The healer looks you over with a professional frown. professional_frown_onceover: The healer looks you over with a professional frown.
@ -524,6 +579,12 @@ town_npc_visit:
tonic_steams_table: A tonic steams on the side table; you hope it is not meant for you. tonic_steams_table: A tonic steams on the side table; you hope it is not meant for you.
blessings_salves_bandages: They mutter blessings while sorting salves and bandages. blessings_salves_bandages: They mutter blessings while sorting salves and bandages.
lighter_under_canvas: You feel oddly lighter just standing under the canvas. lighter_under_canvas: You feel oddly lighter just standing under the canvas.
needle_flash_quick: A needle flashes; you look away like bravery is optional.
wash_basin_cloudy: The wash basin is cloudy — honesty in plumbing.
herb_bundle_label_faded: Herb bundles wear labels faded into myth.
whisper_count_pulse: They whisper numbers that might be pulse or price.
lint_free_bandage_brag: They brag about lint-free bandages. You almost believe.
bitter_tea_offer: Bitter tea is offered as medicine or punishment. Both.
quest_giver: quest_giver:
scrolls_wax_desk: Scrolls and wax seals clutter the quest givers desk. scrolls_wax_desk: Scrolls and wax seals clutter the quest givers desk.
ink_stained_map_tap: They tap a map with an ink-stained finger. ink_stained_map_tap: They tap a map with an ink-stained finger.
@ -531,6 +592,12 @@ town_npc_visit:
draft_parchment_smell: A draft carries the smell of old parchment. draft_parchment_smell: A draft carries the smell of old parchment.
squint_spine_legend: They squint as if measuring your spine against a legend. squint_spine_legend: They squint as if measuring your spine against a legend.
promise_listen_worth_it: You promise to listen; they promise it will be worth it. promise_listen_worth_it: You promise to listen; they promise it will be worth it.
seal_crack_important: A seal cracks; somehow that means 'important.'
chair_squeak_dramatic: The chair squeaks on cue — unpaid sound designer.
window_draft_story: A draft through the window carries someone else's story.
stamp_ink_thumb: Ink stains their thumb like a second seal.
reward_bag_heavier: The reward bag looks heavier than your conscience.
last_hero_failed_joke: They joke that the last hero failed upward. Ha.
generic: generic:
town_noise_blanket: You pause; the town noise folds around you like a blanket. town_noise_blanket: You pause; the town noise folds around you like a blanket.
grain_prices_argument: Someone nearby argues about grain prices in good humor. grain_prices_argument: Someone nearby argues about grain prices in good humor.
@ -538,3 +605,9 @@ town_npc_visit:
strap_tighten_pretend: You tighten a strap and pretend you meant to stop here. strap_tighten_pretend: You tighten a strap and pretend you meant to stop here.
dog_boring_sleeps: A dog watches you, decides you are boring, and sleeps. dog_boring_sleeps: A dog watches you, decides you are boring, and sleeps.
breathe_ready_move_on: You breathe out, ready to move on when the moment feels right. breathe_ready_move_on: You breathe out, ready to move on when the moment feels right.
bell_distant_smith: A distant smith's bell argues with the noon heat.
child_chasing_chicken: A child chases a chicken; civilization holds.
rain_barrel_drip: Rain drips from a barrel like slow percussion.
cloak_smell_smoke: Your cloak smells faintly of smoke and older roads.
notice_board_torn: The notice board is half torn — optimism with teeth.
two_guards_yawn: Two guards yawn in unison; discipline, but sleepy.

@ -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,35 @@ 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: Смотри под кусты.
hero_meet.auto.same_road_twice: Кажется, этот поворот мы уже проходили.
hero_meet.auto.water_skin_low: В бурдюке мало воды — следующий колодец важен.
hero_meet.auto.campfire_smoke_ahead: Впереди дым — может, безопасный костёр.
hero_meet.auto.no_coin_no_story: Нет монеты — нет истории, так у них заведено.
hero_meet.auto.armor_pinch_reminder: Доспех жмёт — всё равно подтягиваешь ремень.
hero_meet.auto.storm_smell_air: Пахнет грозой — или это воображение.
hero_meet.auto.map_wrong_fold: Карта сложена не той стороной — делаешь вид, что так надо.
hero_meet.auto.heard_city_bells: Показалось, слышны городские колокола — наверное птицы.
hero_meet.auto.strap_mended_maybe: Ремень пока держится; «починен» — громко сказано.
hero_meet.auto.monster_or_mud: Может монстр — может грязь. Пульс одинаковый.
hero_meet.auto.share_rations_nod: Поделишься пайком — кивнём и назовём это миром.
hero_meet.auto.night_cold_early: Ночь сегодня ранняя; дорога с этим не согласна.
hero_meet.auto.footprints_cross_yours: Чужие следы пересекают твои — не твоё дело.
hero_meet.auto.quiet_not_safe: Тихо — не значит безопасно; значит надо слушать.
hero_meet.auto.merchant_lied_once: Торговец однажды соврал. Ты всё равно купил похлёбку.
hero_meet.auto.birds_flew_strange: Вчера птицы летели странно. Сегодня нормально. Подозрительно.
achievements: achievements:
first_blood: 'Первая кровь' first_blood: 'Первая кровь'
@ -508,6 +542,21 @@ roadside:
smile_nothing_helps: 'Ты улыбаешься ни о чём. Помогает.' smile_nothing_helps: 'Ты улыбаешься ни о чём. Помогает.'
tomorrow_walk_tonight_breathe: 'Завтра снова пойдёшь. Сегодня просто дышишь.' tomorrow_walk_tonight_breathe: 'Завтра снова пойдёшь. Сегодня просто дышишь.'
grind_volume_down: 'Ты признаёшь, что гринд громкий, и приглушаешь громкость.' grind_volume_down: 'Ты признаёшь, что гринд громкий, и приглушаешь громкость.'
inventory_full_soul: 'Если бы у души были слоты, сожаление было бы легендарным лутом.'
checkpoint_tree_suspicious: 'Это дерево похоже на чекпоинт. Оно отказывается сохранять.'
buff_icon_inner_peace: 'Ищешь иконку баффа «внутренний покой». Не дропается.'
rng_prayer_whisper: 'Шепчешь молитву RNG. В ответ — ветер.'
horse_missing_inventory: 'Воображаемой лошади нет в инвентаре. Трагедия.'
quest_marker_behind_you: 'Метка квеста, наверное, сзади. Классика.'
save_button_reality: 'Хочется кнопки сохранения у реальности. Есть только грязь.'
lag_spirit_anvil: 'Дух отстаёт на удар от тела. Тайминг на наковальне.'
npc_repeat_same_line: 'Кажется, NPC репетируют ту же реплику во всех мирах.'
grass_pixel_perfect: 'Трава обидно красива. Пиксельное совершенство.'
boss_music_birdsong: 'Нет босс-музыки — только птицы. Как-то хуже.'
loot_greed_shame_cycle: 'Мысль о луте, жадность, стыд. Полное комбо.'
hp_bar_poetry_slack: 'Полоска HP — поэзия, написанная вялостью.'
respawn_thought_comfort: 'Респавна нет, но мысль о нём греет.'
roadside_meta_fourth_wall: 'Даже обочина считает, что четвёртая стена продувается.'
town_npc_visit: town_npc_visit:
merchant: merchant:
@ -517,6 +566,12 @@ town_npc_visit:
rumors_bandits_carts: 'Вы обмениваетесь слухами о разбойниках и сломанных осях.' rumors_bandits_carts: 'Вы обмениваетесь слухами о разбойниках и сломанных осях.'
bell_traveler_pack: 'Звенит колокольчик: ещё один путник взваливает рюкзак.' bell_traveler_pack: 'Звенит колокольчик: ещё один путник взваливает рюкзак.'
step_back_tally_gold: 'Ты отступаешь, устно подсчитывая, на что хватит золота.' step_back_tally_gold: 'Ты отступаешь, устно подсчитывая, на что хватит золота.'
scale_dust_counter: 'Пыль на весах превращает каждую монету в спор.'
rope_coil_trips_you: 'Клубок верёвки пытается подставить подножку — дружеская диверсия.'
copper_jingles_pouch: 'Медь звенит так, будто кошелёк её не принадлежит.'
foreign_coin_bite: 'Проверяешь чужую монету зубами. Старая привычка.'
no_credit_today: '''Сегодня без кредита'' вырезано на прилавке как заповедь.'
closing_soon_maybe: 'Бормочут «скоро закрываем» без единой искренности.'
healer: healer:
linens_herbs_tent: 'В палатке пахнет чистым бельём и резкими травами.' linens_herbs_tent: 'В палатке пахнет чистым бельём и резкими травами.'
professional_frown_onceover: 'Целитель окинул тебя взглядом с профессиональным хмурением.' professional_frown_onceover: 'Целитель окинул тебя взглядом с профессиональным хмурением.'
@ -524,6 +579,12 @@ town_npc_visit:
tonic_steams_table: 'На столике дымится отвар; надеешься, он не для тебя.' tonic_steams_table: 'На столике дымится отвар; надеешься, он не для тебя.'
blessings_salves_bandages: 'Бормочут благословения, перекладывая мази и бинты.' blessings_salves_bandages: 'Бормочут благословения, перекладывая мази и бинты.'
lighter_under_canvas: 'Стоя под пологом, чувствуешь себя странно легче.' lighter_under_canvas: 'Стоя под пологом, чувствуешь себя странно легче.'
needle_flash_quick: 'Мелькает игла; ты отводишь взгляд — будто храбрость опциональна.'
wash_basin_cloudy: 'Умывальник мутный — честность в сантехнике.'
herb_bundle_label_faded: 'Пучки трав с этикетками, давно растворившимися в миф.'
whisper_count_pulse: 'Шепчут цифры — пульс или цена, не разобрать.'
lint_free_bandage_brag: 'Хвастаются бинтами без ворса. Ты почти веришь.'
bitter_tea_offer: 'Горький чай — лекарство или наказание. И то и другое.'
quest_giver: quest_giver:
scrolls_wax_desk: 'Стол завален свитками и сургучными печатями.' scrolls_wax_desk: 'Стол завален свитками и сургучными печатями.'
ink_stained_map_tap: 'По карте стучит перстью в чернильных пятнах.' ink_stained_map_tap: 'По карте стучит перстью в чернильных пятнах.'
@ -531,6 +592,12 @@ town_npc_visit:
draft_parchment_smell: 'Сквозняк несёт запах старой бумаги.' draft_parchment_smell: 'Сквозняк несёт запах старой бумаги.'
squint_spine_legend: 'Щурятся, будто меряют тебя легендой напротив.' squint_spine_legend: 'Щурятся, будто меряют тебя легендой напротив.'
promise_listen_worth_it: 'Ты обещаешь слушать; обещают, что оно того стоит.' promise_listen_worth_it: 'Ты обещаешь слушать; обещают, что оно того стоит.'
seal_crack_important: 'Трескается печать — и это значит «важно».'
chair_squeak_dramatic: 'Стул скрипит вовремя — звукорежиссёр не получил золото.'
window_draft_story: 'Сквозняк из окна приносит чужую историю.'
stamp_ink_thumb: 'Чернила на пальце — второй печати хватает.'
reward_bag_heavier: 'Мешок с наградой выглядит тяжелее совести.'
last_hero_failed_joke: 'Шутят, что прошлый герой «ошибся вверх». Ха.'
generic: generic:
town_noise_blanket: 'Ты замираешь; городской шум обволакивает, как одеяло.' town_noise_blanket: 'Ты замираешь; городской шум обволакивает, как одеяло.'
grain_prices_argument: 'Рядом в шутку спорят о цене на зерно.' grain_prices_argument: 'Рядом в шутку спорят о цене на зерно.'
@ -538,3 +605,9 @@ town_npc_visit:
strap_tighten_pretend: 'Подтягиваешь ремень и делаешь вид, что так и задумано.' strap_tighten_pretend: 'Подтягиваешь ремень и делаешь вид, что так и задумано.'
dog_boring_sleeps: 'Собака смотрит, решает, что ты скучен, и засыпает.' dog_boring_sleeps: 'Собака смотрит, решает, что ты скучен, и засыпает.'
breathe_ready_move_on: 'Выдыхаешь — готов идти дальше, когда будет пора.' breathe_ready_move_on: 'Выдыхаешь — готов идти дальше, когда будет пора.'
bell_distant_smith: 'Далёкий колокол кузни спорит с полуденным зноем.'
child_chasing_chicken: 'Ребёнок гоняет курицу — цивилизация держится.'
rain_barrel_drip: 'С бочки капает дождь — медленная перкуссия.'
cloak_smell_smoke: 'Плащ пахнет дымом и более старыми дорогами.'
notice_board_torn: 'Доска объявлений изорвана вполовину — оптимизм с зубами.'
two_guards_yawn: 'Два стража зевают синхронно — дисциплина, но сонная.'

@ -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,12 +112,41 @@ 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: real Mini App user wins when present (must match initData on server).
* Dev-only: ?telegramId= on localhost/LAN when not in Telegram or before user is available.
*/
export function getTelegramUserId(): number | null { export function getTelegramUserId(): number | null {
const tg = getTelegramWebApp(); const tg = getTelegramWebApp();
if (!tg) return null; const fromTg = tg
const user = tg.initDataUnsafe?.user as { id?: number } | undefined; ? (tg.initDataUnsafe?.user as { id?: number } | undefined)?.id
return user?.id ?? null; : undefined;
if (fromTg != null && fromTg > 0) {
return fromTg;
}
const fromQuery = readDevTelegramIdFromQuery();
if (fromQuery != null) return fromQuery;
return null;
} }
/** Get viewport dimensions accounting for Telegram safe areas */ /** Get viewport dimensions accounting for Telegram safe areas */

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