You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2758 lines
164 KiB
HTML

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoHero Admin</title>
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #10141f; color: #e8eef9; }
.layout { display: grid; grid-template-columns: 230px 1fr; min-height: 100vh; }
.nav { background: #151b2a; padding: 16px; border-right: 1px solid #2a3551; }
.brand { font-weight: 700; margin-bottom: 16px; }
.nav button { width: 100%; margin: 6px 0; padding: 10px; background: #202a40; color: #fff; border: 1px solid #2f3b5a; cursor: pointer; text-align: left; }
.nav button.active { background: #3d5a94; }
.main { padding: 16px; }
.card { background: #151b2a; border: 1px solid #2a3551; border-radius: 8px; padding: 14px; margin-bottom: 12px; }
.panel { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.row-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
input, select { width: 100%; padding: 8px; margin: 4px 0; background: #0f1522; color: #fff; border: 1px solid #2f3b5a; }
.btn { padding: 8px 12px; border: 1px solid #2f3b5a; background: #223152; color: #fff; cursor: pointer; margin-right: 8px; margin-top: 8px; }
.btn.warn { background: #7a3a3a; }
.muted { color: #9eb0d6; font-size: 13px; }
.list { border: 1px solid #2a3551; border-radius: 6px; max-height: 360px; overflow: auto; }
.list-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items: center; padding: 8px; border-bottom: 1px solid #222f49; cursor: pointer; }
.list-row:last-child { border-bottom: 0; }
.list-row:hover { background: #182238; }
.list-row.active { background: #24365b; }
.kv { display: grid; grid-template-columns: 180px 1fr; gap: 8px; margin: 5px 0; }
.kv kbd { background: #0f1522; border: 1px solid #2f3b5a; padding: 3px 6px; border-radius: 4px; font-family: monospace; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid #25324d; padding: 8px; text-align: left; }
.table tr:hover td { background: #182238; }
.pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.status-ok { color: #7de29f; font-size: 12px; }
.status-err { color: #ff8f8f; font-size: 12px; }
.table td kbd[title], .table td input[title] { cursor: help; }
.runtime-const-group { margin-top: 20px; }
.runtime-const-group:first-child { margin-top: 0; }
.runtime-const-group-title { font-size: 14px; margin: 0 0 8px; font-weight: 600; color: #cfe3ff; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
.modal { width: 420px; max-width: 95vw; background: #151b2a; border: 1px solid #2a3551; border-radius: 8px; padding: 14px; }
.live-ws-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; margin-top: 8px; }
.live-ws-bar-main { flex: 1; min-width: 220px; }
.live-ws-bar-actions { flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; align-items: stretch; }
.live-ws-bar-actions .btn { margin-top: 0; margin-right: 0; }
details.live-details > summary { cursor: pointer; color: #cfe3ff; font-weight: 600; list-style-position: outside; }
details.live-details > summary::-webkit-details-marker { color: #9eb0d6; }
.quest-world-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid #2a3551; }
.jv-root { margin-top: 8px; font-family: ui-monospace, Consolas, monospace; font-size: 12px; line-height: 1.45; }
.jv-node { margin: 2px 0 2px 0; border-left: 1px solid #2f3b5a; padding-left: 8px; }
.jv-node > .jv-sum { cursor: pointer; color: #9eb0d6; user-select: none; }
.jv-node > .jv-sum:hover { color: #cfe3ff; }
.jv-ch { margin-top: 4px; padding-left: 4px; }
.jv-row { margin: 2px 0; }
.jv-key { color: #7eb8ff; margin-right: 4px; }
.jv-idx { color: #b8a0ff; margin-right: 4px; }
.jv-str { color: #7de29f; }
.jv-lit { color: #ffb86c; }
.jv-null { color: #9eb0d6; font-style: italic; }
.jv-empty { color: #6a7a9e; }
.combat-live-modal { width: min(960px, 98vw); max-width: 98vw; max-height: 94vh; overflow: hidden; display: flex; flex-direction: column; padding: 0; }
.combat-live-modal h3 { margin: 0 0 6px 0; font-size: 18px; }
.combat-live-head { padding: 12px 16px 8px 16px; border-bottom: 1px solid #2a3551; flex-shrink: 0; }
.combat-live-body { display: flex; flex: 1; min-height: 0; gap: 0; }
.combat-arena-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.combat-arena {
position: relative; flex: 1; min-height: 300px;
background: radial-gradient(ellipse 120% 80% at 50% 100%, #1a2838 0%, #0d121c 45%, #080b10 100%);
border-bottom: 1px solid #2a3551;
overflow: hidden;
}
.combat-arena::before {
content: ""; position: absolute; left: 8%; right: 8%; bottom: 18%; height: 38%;
background: linear-gradient(180deg, transparent 0%, rgba(20,35,55,.35) 100%);
border-radius: 50% 50% 0 0 / 40% 40% 0 0;
pointer-events: none;
}
@keyframes combatArenaShake {
0%, 100% { transform: translate(0,0); }
20% { transform: translate(-5px, 2px); }
40% { transform: translate(5px, -2px); }
60% { transform: translate(-3px, -1px); }
80% { transform: translate(3px, 1px); }
}
.combat-arena--shake { animation: combatArenaShake 0.14s ease-out; }
.combat-arena-inner { position: relative; z-index: 1; height: 100%; display: flex; justify-content: space-between; align-items: flex-end; padding: 16px 6% 28px 6%; box-sizing: border-box; }
.combat-side { width: 42%; max-width: 220px; display: flex; flex-direction: column; align-items: center; text-align: center; }
.combat-side--hero { align-items: flex-start; text-align: left; }
.combat-side--enemy { align-items: flex-end; text-align: right; }
.combat-portrait {
position: relative; width: 112px; height: 112px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 52px;
box-shadow: 0 8px 24px rgba(0,0,0,.5), inset 0 0 0 3px rgba(255,255,255,.08);
margin-bottom: 8px;
transition: transform 0.08s ease-out, filter 0.1s;
}
.combat-portrait--hero { background: linear-gradient(145deg, #2d4a6f, #1a2740); }
.combat-portrait--enemy { background: linear-gradient(145deg, #6b2d3a, #3a1820); }
.combat-portrait--flash-hero { filter: brightness(1.35); box-shadow: 0 0 22px rgba(255,100,100,.45), inset 0 0 0 3px rgba(255,200,200,.25); }
.combat-portrait--flash-enemy { filter: brightness(1.35); box-shadow: 0 0 22px rgba(100,200,255,.4), inset 0 0 0 3px rgba(200,230,255,.2); }
.combat-portrait-label { font-size: 13px; font-weight: 700; color: #e8eef9; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 6px; }
.combat-portrait-sub { font-size: 11px; color: #8a9bb8; margin-bottom: 8px; }
.combat-arena-hp { width: 100%; }
.combat-arena-hp .combat-hp-track { height: 18px; border-radius: 6px; }
.combat-arena-hp .combat-hp-meta { font-size: 12px; font-weight: 600; color: #cfe3ff; margin-top: 4px; }
.combat-float-dmg {
position: absolute; bottom: 108px; font-weight: 900; font-size: 26px; text-shadow: 0 2px 6px rgba(0,0,0,.9), 0 0 12px rgba(0,0,0,.6);
pointer-events: none; z-index: 4;
animation: combatDmgPop 0.55s cubic-bezier(0.2, 0.85, 0.3, 1) forwards;
}
.combat-float-dmg--hero { left: 12%; color: #ff8a8a; }
.combat-float-dmg--enemy { right: 12%; color: #ffffff; }
.combat-float-dmg--crit { font-size: 34px; color: #ffe566; }
.combat-float-dmg--txt { font-size: 15px; color: #a8e0ff; }
@keyframes combatDmgPop {
0% { opacity: 0; transform: translateY(12px) scale(0.7); }
15% { opacity: 1; transform: translateY(0) scale(1.05); }
100% { opacity: 0.92; transform: translateY(-36px) scale(1); }
}
.combat-log-panel {
width: 300px; flex-shrink: 0; display: flex; flex-direction: column;
background: rgba(8, 10, 16, 0.92); border-left: 1px solid #2a3551;
}
.combat-log-panel-title {
font-size: 10px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase;
color: rgba(180, 195, 220, 0.85); padding: 10px 12px 4px 12px;
}
.combat-live-log-scroll {
flex: 1; min-height: 0; overflow-y: auto; padding: 0 10px 12px 12px;
font-family: ui-monospace, Consolas, monospace; font-size: 10px; line-height: 1.5;
}
.combat-feed-line { margin: 0 0 3px 0; word-break: break-word; color: #c8c8d8; }
.combat-feed-line--tick { color: #5a6578; }
.combat-feed-line--attack { color: #e8ecf5; }
.combat-feed-line--death { color: #ff8f8f; font-weight: 700; }
.combat-live-foot { padding: 10px 16px; border-top: 1px solid #2a3551; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; flex-shrink: 0; }
.combat-hp-row { display: flex; align-items: center; gap: 10px; }
.combat-hp-label { width: 88px; font-size: 12px; color: #9eb0d6; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.combat-hp-meta { font-size: 11px; color: #7a8ab0; min-width: 72px; text-align: right; }
.combat-hp-track { flex: 1; height: 24px; background: #0f1522; border-radius: 4px; overflow: hidden; border: 1px solid #2f3b5a; }
.combat-hp-fill { height: 100%; transition: width 0.07s linear; }
.combat-hp-fill.hero { background: linear-gradient(90deg, #1b4332, #40916c); }
.combat-hp-fill.enemy { background: linear-gradient(90deg, #6a040f, #e85d75); }
.hero-search-details > summary { cursor: pointer; color: #cfe3ff; font-weight: 600; list-style-position: outside; }
.hero-search-details > summary .muted { font-weight: 400; }
.heroes-tab-layout { display: flex; flex-direction: column; gap: 12px; }
.hero-details-grid { display: grid; grid-template-columns: 1fr minmax(300px, 420px); gap: 16px; align-items: start; }
@media (max-width: 1100px) {
.hero-details-grid { grid-template-columns: 1fr; }
}
.hero-details-actions { border-left: 1px solid #2a3551; padding-left: 14px; min-width: 0; }
@media (max-width: 1100px) {
.hero-details-actions { border-left: 0; padding-left: 0; border-top: 1px solid #2a3551; padding-top: 12px; margin-top: 4px; }
}
.hero-details-actions h4 { margin: 0 0 8px 0; font-size: 14px; color: #cfe3ff; }
.hero-actions-inputs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 8px; }
.hero-actions-btns { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.hero-actions-btns .btn { margin-top: 0; margin-right: 0; }
.hero-teleport-row { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: end; margin-top: 10px; }
</style>
</head>
<body>
<div id="app"></div>
<script>
const state = {
tab: "server",
auth: { username: sessionStorage.getItem("admin_user") || "", password: sessionStorage.getItem("admin_pass") || "" },
message: "",
heroes: [],
selectedHeroId: null,
selectedHero: null,
serverInfo: null,
engine: null,
runtime: null,
buffDebuff: null,
payments: [],
paymentDetail: null,
gear: null,
gearCatalog: [],
quests: null,
questTowns: [],
townNpcs: [],
npcQuests: [],
contentQuests: [],
contentGearRows: [],
contentGearEditor: null,
contentEnemies: [],
contentEnemyEditor: null,
combatSimForm: { heroId: "", heroQuery: "", heroPickName: "", enemyType: "", enemyFilter: "", enemyLevel: "", delayMs: 0, maxEvents: 400 },
combatSimHeroRows: [],
combatSimResult: null,
combatSimLive: null,
_combatSimLiveTimer: null,
contentQuestEditor: null,
gearFilterSlot: "",
gearFilterRarity: "",
gearFilterSubtype: "",
grantGearSearchQuery: "",
heroGrantGearCandidates: [],
/** NPC list for «Подойти к NPC» (town tour admin); from GET quests/towns/{id}/npcs */
townTourApproachNpcs: [],
townTourApproachNpcTownId: null,
heroGrantFilterSlot: "",
heroGrantFilterRarity: "",
heroGrantFilterSubtype: "",
teleportTowns: [],
pages: {},
rowStatus: {},
confirm: { open: false, title: "", message: "" },
_heroPollTimer: null,
_heroPollUntil: null,
_heroLiveWs: null,
_heroLiveWsHeroId: null,
_heroLiveWsStatus: "disconnected",
_heroLiveWsError: "",
_heroLiveWsLastAt: null,
_liveSnapshotOpen: false,
_heroQuestWorldOpen: false,
/** Heroes tab: collapsible «Поиск героя» panel */
_heroSearchOpen: false,
_heroLiveSnapshot: null,
_jsonViewerOpenPaths: null,
/** Preserves hero detail form inputs across live WS/poll updates (no full render). */
heroAdminDraft: { hp: "", gold: "", level: "", subPeriods: "1" },
heroAdminDraftForId: null,
};
state._confirmAction = null;
/** Matches model.AllBuffTypes / AllDebuffTypes (admin manual apply). */
const ADMIN_BUFF_TYPES = ["rush", "rage", "shield", "luck", "resurrection", "heal", "power_potion", "war_cry"];
const ADMIN_DEBUFF_TYPES = ["poison", "freeze", "burn", "stun", "slow", "weaken", "ice_slow"];
/** model.SpecialAbility — enemy template abilities (DB text[]). */
const ADMIN_ENEMY_ABILITIES = ["burn", "slow", "critical", "poison", "freeze", "ice_slow", "stun", "dodge", "regen", "burst", "chain_lightning", "summon"];
function e(v) { return String(v ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); }
/** Gear/quest/enemy modal editors: hero poll + WS call render() often; syncing fields here keeps edits across re-renders. */
function hasOpenContentEditor() {
return !!(state.contentEnemyEditor || state.contentGearEditor || state.contentQuestEditor);
}
function clearHeroAdminDraft() {
state.heroAdminDraft = { hp: "", gold: "", level: "", subPeriods: "1" };
state.heroAdminDraftForId = null;
}
/**
* Updates hero detail DOM from state without full render — keeps JSON tree expansion (_jsonViewerOpenPaths),
* live details open state, and heroAdminDraft form fields intact.
* @param {{ updateJsonTree?: boolean }} opts — set updateJsonTree when WS payload refreshed _heroLiveSnapshot
*/
function patchHeroLiveDom(opts) {
const updateJson = opts && opts.updateJsonTree;
if (state.tab !== "heroes" || !state.selectedHero) return false;
const h = state.selectedHero;
const $ = (id) => document.getElementById(id);
if (!$("hero-detail-level")) return false;
$("hero-detail-level").textContent = h.level;
const hpEl = $("hero-detail-hp");
if (hpEl) hpEl.textContent = `${h.hp}/${h.maxHp}`;
const gEl = $("hero-detail-gold");
if (gEl) gEl.textContent = h.gold;
const stEl = $("hero-detail-state");
if (stEl) stEl.textContent = h.state;
const subEl = $("hero-detail-subscription");
if (subEl) {
subEl.textContent = `${h.subscriptionActive ? "active" : "inactive"}${h.subscriptionExpiresAt ? " · until " + h.subscriptionExpiresAt : ""}`;
}
const mov = $("hero-movement-panel");
if (mov) mov.outerHTML = heroMovementDetailHtml(h);
const wsLast = $("hero-ws-last");
if (wsLast) {
wsLast.textContent = state._heroLiveWsLastAt ? new Date(state._heroLiveWsLastAt).toLocaleTimeString() : "—";
}
const wsSt = $("hero-ws-status");
if (wsSt) {
const st = state._heroLiveWsStatus || "disconnected";
const hid = state._heroLiveWsHeroId;
wsSt.textContent = hid ? `${st} (hero ${hid})` : st;
}
const wsErr = $("hero-ws-err");
if (wsErr) {
const err = state._heroLiveWsError || "";
wsErr.textContent = err;
wsErr.className = err ? "status-err" : "";
}
if (updateJson) {
const jsonRoot = $("hero-ws-json-root");
if (jsonRoot) patchOrRebuildHeroJsonTree(jsonRoot);
}
return true;
}
function patchMonsterField(field, value) {
const ed = state.contentEnemyEditor;
if (!ed) return;
if (field === "isElite") {
ed.isElite = !!value;
return;
}
if (field === "abilities") {
ed._abilitiesText = String(value);
return;
}
const numKeys = ["id", "maxHp", "attack", "defense", "speed", "critChance", "minLevel", "maxLevel", "xpReward", "goldReward"];
if (numKeys.includes(field)) {
const n = Number(value);
ed[field] = Number.isFinite(n) ? n : 0;
return;
}
ed[field] = value;
}
function authHeader() { return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`; }
function setMessage(text) { state.message = text; render(); }
function setRowStatus(key, ok, message) { state.rowStatus[key] = { ok, message }; render(); }
function clearRowStatus(key) { delete state.rowStatus[key]; }
function openConfirm(title, message, onConfirm) {
state.confirm = { open: true, title, message };
state._confirmAction = onConfirm;
render();
}
function closeConfirm() {
state.confirm = { open: false, title: "", message: "" };
state._confirmAction = null;
render();
}
async function confirmProceed() {
if (!state._confirmAction) return closeConfirm();
const fn = state._confirmAction;
closeConfirm();
await withAction(fn);
}
function pageOf(key) { return state.pages[key] || 1; }
function setPage(key, page) { state.pages[key] = Math.max(1, page); render(); }
function paged(items, key, size) {
const list = items || [];
const page = pageOf(key);
const start = (page - 1) * size;
const total = Math.max(1, Math.ceil(list.length / size));
if (page > total) state.pages[key] = total;
return { items: list.slice(start, start + size), page: state.pages[key] || page, total };
}
function pagerHtml(key, page, total) {
return `<div class="pager">
<button class="btn" ${page<=1?"disabled":""} onclick="setPage('${e(key)}', ${page-1})">Prev</button>
<span class="muted">Page ${page} / ${total}</span>
<button class="btn" ${page>=total?"disabled":""} onclick="setPage('${e(key)}', ${page+1})">Next</button>
</div>`;
}
async function api(path, opts = {}) {
const headers = Object.assign({ "Authorization": authHeader(), "Content-Type": "application/json" }, opts.headers || {});
const res = await fetch(`/admin-api/${path}`, Object.assign({ cache: "no-store" }, opts, { headers }));
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
if (res.status === 204) return null;
return res.json();
}
async function withAction(fn) { try { await fn(); } catch (err) { setMessage(`Error: ${String(err.message || err)}`); } }
async function withRowAction(key, fn, okMsg) {
clearRowStatus(key);
try {
await fn();
setRowStatus(key, true, okMsg || "Done");
} catch (err) {
setRowStatus(key, false, String(err.message || err));
}
}
function flattenObject(input, prefix = "", out = []) {
if (input == null || typeof input !== "object" || Array.isArray(input)) return out;
const keys = Object.keys(input).sort();
for (const key of keys) {
const path = prefix ? `${prefix}.${key}` : key;
const value = input[key];
if (value && typeof value === "object" && !Array.isArray(value)) flattenObject(value, path, out);
else out.push({ path, value });
}
return out;
}
/** What the server actually runs (merged); DB `payload` alone is often {}. */
function runtimeDisplaySource() {
const rt = state.runtime;
if (!rt) return {};
if (rt.effective && typeof rt.effective === "object" && !Array.isArray(rt.effective)) return rt.effective;
if (rt.payload && typeof rt.payload === "object" && !Array.isArray(rt.payload)) return rt.payload;
return {};
}
function formatRuntimeInputValue(v) {
if (v == null) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
/** RU tooltips for tuning keys (backend internal/tuning.Values). Unknown keys: fallback in runtimeTooltipForPath. */
const RUNTIME_CONSTANT_TOOLTIPS = {
encounterCooldownBaseMs: "Базовая задержка между проверками столкновений с врагами при движении, мс.",
encounterActivityBase: "Базовая «активность» — влияет на частоту случайных столкновений при ходьбе (вероятностный множитель цикла).",
baseMoveSpeed: "Базовая скорость перемещения героя по миру в world units/сек до баффов и экипировки.",
movementTickRateMs: "Частота тика системы движения, мс. Слишком маленькое значение увеличит нагрузку на сервер.",
positionSyncRateMs: "Как часто сервер шлёт полный position_sync для коррекции дрейфа, мс.",
townRestMinMs: "Минимальная длительность отдыха в городе (таверна), мс.",
townRestMaxMs: "Максимальная длительность отдыха в городе, мс.",
townRestHpPerSecond: "Доля MaxHP за секунду при отдыхе в городе.",
townArrivalRadius: "Радиус прибытия в город: при каком расстоянии до узла путь считается завершённым.",
townNpcVisitChance: "Вероятность за цикл посетить NPC в городе (после входа в город).",
townNpcRollMinMs: "Минимальная задержка между «бросками» визита к NPC в городе, мс.",
townNpcRollMaxMs: "Максимальная задержка между бросками визита к NPC, мс.",
townNpcRetryMs: "Пауза перед повтором попытки взаимодействия с NPC, мс.",
townNpcPauseMs: "Длительность паузы между этапами городского NPC-цикла, мс.",
townNpcLogIntervalMs: "Интервал логирования активности городских NPC, мс.",
wanderingMerchantPromptTimeoutMs: "Таймаут ожидания ответа на предложение бродячего торговца, мс.",
merchantCostBase: "Базовая цена (золото) услуг торговца.",
merchantCostPerLevel: "Добавка к цене торговца за уровень героя.",
merchantTownAutoSellShare: "Доля инвентаря (0..1), которую торговец в городе пытается автоматически продать при визите.",
monsterEncounterWeightBase: "Базовый вес события «встреча с монстром» при выборе типа столкновения.",
monsterEncounterWeightWildBonus: "Бонус к весу монстров вне дороги / «дикой» зоне.",
merchantEncounterWeightBase: "Базовый вес события «торговец».",
merchantEncounterWeightRoadBonus: "Бонус к весу торговца на дороге.",
lootChanceCommon: "Относительный шанс редкости лута: common (сумма с другими редкостями задаёт доли).",
lootChanceUncommon: "Относительный шанс лута: uncommon.",
lootChanceRare: "Относительный шанс лута: rare.",
lootChanceEpic: "Относительный шанс лута: epic.",
lootChanceLegendary: "Относительный шанс лута: legendary.",
goldLootScale: "Общий множитель к золоту с дропа.",
potionDropChance: "Базовая вероятность выпадения зелья после боя.",
equipmentDropBase: "Базовый множитель шанса выпадения экипировки (умножается на модификатор удачи).",
goldCommonMin: "Мин. золото за добычу уровня common.",
goldCommonMax: "Макс. золото за common.",
goldUncommonMin: "Мин. золото за uncommon.",
goldUncommonMax: "Макс. золото за uncommon.",
goldRareMin: "Мин. золото за rare.",
goldRareMax: "Макс. золото за rare.",
goldEpicMin: "Мин. золото за epic.",
goldEpicMax: "Макс. золото за epic.",
goldLegendaryMin: "Мин. золото за legendary.",
goldLegendaryMax: "Макс. золото за legendary.",
autoSellCommon: "Автопродажа: золото за common при автосейле.",
autoSellUncommon: "Автопродажа: золото за uncommon.",
autoSellRare: "Автопродажа: золото за rare.",
autoSellEpic: "Автопродажа: золото за epic.",
autoSellLegendary: "Автопродажа: золото за legendary.",
restEncounterCooldownMs: "Кулдаун после «REST»-события столкновения, мс.",
restEncounterNpcChance: "Вероятность NPC-события в контексте REST-столкновений.",
npcCostHeal: "Стоимость лечения у NPC, золото.",
npcCostPotion: "Стоимость зелья у NPC, золото.",
npcCostNearbyRadius: "Радиус «рядом с NPC» для ценообразования/проверок.",
combatDamageScale: "Глобальный масштаб исходящего урона героя в бою.",
combatDamageRollMin: "Мин. множитель случайного ролла урона (до защиты и крита).",
combatDamageRollMax: "Макс. множитель случайного ролла урона (до защиты и крита).",
heroCritChanceCap: "Верхняя граница шанса крита героя.",
heroBlockChancePerDefense: "Шанс блока героя на 1 единицу Defense.",
heroBlockChanceCap: "Верхняя граница шанса блока героя.",
enemyDodgeChance: "Базовая вероятность уклонения врага.",
enemyCriticalMinChance: "Нижняя планка шанса крита врага.",
enemyCritChanceCap: "Верхняя граница шанса крита врага.",
enemyBurstEveryN: "Враг наносит «всплеск» урона каждые N своих атак.",
enemyBurstMultiplier: "Множитель урона при всплеске.",
enemyChainEveryN: "Цепная атака врага каждые N ударов.",
enemyChainMultiplier: "Множитель урона цепной атаки.",
debuffProcBurn: "Вероятность срабатывания горения (дот) у врагов с этим эффектом.",
debuffProcPoison: "Вероятность срабатывания яда.",
debuffProcSlow: "Вероятность замедления.",
debuffProcStun: "Вероятность оглушения.",
debuffProcFreeze: "Вероятность заморозки.",
debuffProcIceSlow: "Вероятность ледяного замедления.",
enemyRegenDefault: "Регенерация HP у врагов по умолчанию (доля или коэфф. за тик).",
enemyRegenSkeletonKing: "Регенерация для скелета-короля и аналогов.",
enemyRegenForestWarden: "Регенерация для лесного стража.",
enemyRegenBattleLizard: "Регенерация для боевой ящерицы.",
summonCycleSeconds: "Период призыва миньонов у врагов, с.",
summonDamageDivisor: "Делитель урона призванных существ.",
luckBuffMultiplier: "Множитель влияния удачи на лут.",
minAttackIntervalMs: "Минимальный интервал между атаками (нижняя граница скорости), мс.",
combatPaceMultiplier: "Множитель к интервалу атак в бою; чем выше — тем реже удары (длиннее паузы).",
enemyAttackIntervalMultiplier: "Только враги: множитель к их интервалу атак (герой без изменений). Выше — реже, но обычно паруют с enemyCombatDamageScale.",
enemyCombatDamageScale: "Масштаб урона врага по герою (входящий урон за удар).",
enemyCombatDamageRollMin: "Мин. ролл входящего урона врага.",
enemyCombatDamageRollMax: "Макс. ролл входящего урона врага.",
potionHealPercent: "Доля MaxHP, восстанавливаемая зельем.",
potionAutoUseThreshold: "При какой доле MaxHP автоиспользовать зелье (если включено).",
reviveHpPercent: "Доля MaxHP после воскрешения.",
autoReviveAfterMs: "Автовоскрешение через столько мс после смерти (если механика активна).",
xpCurveEarlyBase: "XP-кривая: ранняя фаза, база.",
xpCurveEarlyScale: "XP-кривая: ранняя фаза, множитель роста.",
xpCurveMidBase: "XP-кривая: средняя фаза, база.",
xpCurveMidScale: "XP-кривая: средняя фаза, множитель.",
xpCurveLateBase: "XP-кривая: поздняя фаза, база.",
xpCurveLateScale: "XP-кривая: поздняя фаза, множитель.",
levelUpHpEvery: "Через сколько уровней добавляется бонус к HP при левелапе.",
levelUpAtkEvery: "Через сколько уровней бонус к атаке.",
levelUpDefEvery: "Через сколько уровней бонус к защите.",
levelUpStrEvery: "Через сколько уровней бонус к силе.",
levelUpConEvery: "Через сколько уровней бонус к телосложению.",
levelUpAgiEvery: "Через сколько уровней бонус к ловкости.",
levelUpLuckEvery: "Через сколько уровней бонус к удаче.",
agilityCoef: "Коэффициент влияния ловкости на интервал атаки (спека: agility_coef).",
maxAttackSpeed: "Максимальная атак в секунду (или верхняя граница скорости).",
minAttackSpeed: "Минимальная атак/с (нижняя граница).",
ilvlFactorSlope: "Наклон формулы влияния ilvl на статы предмета.",
rarityMultiplierCommon: "Множитель статов предмета: common.",
rarityMultiplierUncommon: "Множитель статов: uncommon.",
rarityMultiplierRare: "Множитель статов: rare.",
rarityMultiplierEpic: "Множитель статов: epic.",
rarityMultiplierLegendary: "Множитель статов: legendary.",
rollIlvlEliteBaseChance: "Шанс при roll ilvl для элиты: базовый ilvl.",
rollIlvlElitePlusOneChance: "Шанс «ilvl +1» для элитного дропа.",
autoEquipThreshold: "Минимальный множитель улучшения combat rating: выше порога — предмет надевается при автоэкипировке.",
buffChargePeriodMs: "Период накопления зарядов бесплатных бафов, мс.",
freeBuffActivationsPerPeriod: "Сколько бесплатных активаций бафа за период.",
subscriptionDurationMs: "Длительность подписки по умолчанию, мс.",
subscriptionWeeklyPriceRub: "Недельная цена подписки, RUB (отображение/платёжка).",
buffRefillPriceRub: "Цена покупки refill для обычного баффа, RUB.",
resurrectionRefillPriceRub: "Цена refill для Resurrection, RUB.",
maxRevivesFree: "Максимум воскрешений за период без подписки.",
maxRevivesSubscriber: "Максимум воскрешений для подписчика за период.",
enemyScaleBandHp: "Масштаб врага по уровню: вклад HP в «основной» диапазон уровней.",
enemyScaleOvercapHp: "Доп. масштаб HP сверх диапазона уровня.",
enemyScaleBandAtk: "Масштаб ATK в основном диапазоне.",
enemyScaleOvercapAtk: "Доп. масштаб ATK сверх диапазона.",
enemyScaleBandDef: "Масштаб DEF в основном диапазоне.",
enemyScaleOvercapDef: "Доп. масштаб DEF сверх диапазона.",
enemyScaleBandXp: "Масштаб награды XP от уровня врага.",
enemyScaleOvercapXp: "Доп. масштаб XP сверх диапазона.",
enemyScaleBandGold: "Масштаб золота с врага.",
enemyScaleOvercapGold: "Доп. масштаб золота сверх диапазона.",
lootHistoryLimit: "Сколько последних записей лута хранить в оперативной истории на героя (для UI/логов).",
adventureStartChance: "Вероятность за тик движения начать мини-приключение (~3 за 8 ч при 500 мс тике и ~50% времени в ходьбе).",
adventureCooldownMs: "Минимальный интервал между сессиями мини-приключения, мс.",
adventureOutDurationMs: "Длительность фазы «уход с дороги в лес», мс.",
adventureWildMinMs: "Мин. длительность фазы «в лесу» (энкаунтеры), мс. Сумма out+wild+return задаёт полную длительность.",
adventureWildMaxMs: "Макс. длительность фазы «в лесу», мс.",
adventureReturnDurationMs: "Длительность фазы «возврат к дороге», мс.",
adventureDepthWorldUnits: "Макс. смещение перпендикулярно дороге (глубина «в лес»), world units.",
adventureEncounterCooldownMs: "Кулдаун между энкаунтерами в фазах wild/return, мс.",
adventureReturnEncounterEnabled: "Разрешить энкаунтеры на фазе возврата к дороге (true/false).",
lowHpThreshold: "Доля HP/MaxHP, ниже которой может сработать отдых у обочины / в приключении.",
roadsideRestExitHp: "Доля HP/MaxHP — при достижении можно выйти из отдыха у обочины (ранний выход).",
adventureRestTargetHp: "Целевая доля HP/MaxHP для выхода из inline-отдыха во время приключения.",
roadsideRestMinMs: "Мин. длительность отдыха у обочины, мс.",
roadsideRestMaxMs: "Макс. длительность отдыха у обочины, мс.",
roadsideRestHpPerSecond: "Доля MaxHP восстановления в секунду при отдыхе у обочины.",
adventureRestHpPerSecond: "Доля MaxHP восстановления в секунду при inline-отдыхе в приключении."
};
/** Display order and RU titles for runtime constant groups (admin UI). */
const RUNTIME_CONSTANT_GROUPS_ORDER = [
{ id: "road_encounters", title: "Дорога: кулдаун и активность (частота проверок столкновений)" },
{ id: "movement_world", title: "Мир: скорость ходьбы, тик движения, синхрон позиции, вход в город" },
{ id: "town_rest", title: "Город: отдых и скорость восстановления HP" },
{ id: "town_npc", title: "Город: NPC — шанс визита и тайминги" },
{ id: "merchant", title: "Торговец: цены, доля автосейла в городе, таймаут бродячего" },
{ id: "encounter_weights", title: "Типы встреч: веса «монстр / торговец» (дорога и дикая зона)" },
{ id: "excursion", title: "Мини-приключение (лес): шанс старта, фазы out/wild/return, глубина, кулдауны" },
{ id: "roadside_hp_rest", title: "Обочина и низкий HP: пороги, длительность отдыха, регенерация" },
{ id: "loot_rarity", title: "Лут: относительные шансы редкости предмета" },
{ id: "loot_extra", title: "Лут: зелья, экипировка, масштаб золота, влияние удачи" },
{ id: "gold_tiers", title: "Лут: диапазоны золота по тиру редкости" },
{ id: "autosell", title: "Автопродажа: золото за редкость" },
{ id: "postfight_rest", title: "После боя: REST-цикл и шанс NPC" },
{ id: "npc_costs", title: "NPC: цены услуг и радиус" },
{ id: "hero_combat", title: "Бой героя: урон, темп и скорость атаки" },
{ id: "enemy_combat", title: "Бой врага: уклонение, крит, всплеск и цепь урона" },
{ id: "debuff_proc", title: "Бой: шансы срабатывания дебаффов" },
{ id: "enemy_special", title: "Враги: регенерация, призыв, урон призванных" },
{ id: "potion_revive", title: "Зелья, воскрешение и таймер авто-ревайва" },
{ id: "xp_curve", title: "Прогрессия: кривая опыта (фазы)" },
{ id: "levelup", title: "Прогрессия: как часто растут статы при уровне" },
{ id: "gear_scaling", title: "Экипировка: ilvl, множители редкости, ролл ilvl у элиты" },
{ id: "inventory_meta", title: "Инвентарь: автоэкипировка и история лута" },
{ id: "buff_quota", title: "Баффы: период зарядов и число бесплатных активаций" },
{ id: "monetization", title: "Монетизация: подписка, цены refill (бафф / воскрешение), лимиты ревайвов" },
{ id: "enemy_scale", title: "Враги: масштаб статов и наград по уровню" },
{ id: "other", title: "Прочее" }
];
/** Last path segment (flat tuning key) → group id. Must cover all tuning.Value JSON keys. */
const RUNTIME_KEY_TO_GROUP = {
encounterCooldownBaseMs: "road_encounters",
encounterActivityBase: "road_encounters",
baseMoveSpeed: "movement_world",
movementTickRateMs: "movement_world",
positionSyncRateMs: "movement_world",
townRestMinMs: "town_rest",
townRestMaxMs: "town_rest",
townRestHpPerSecond: "town_rest",
townArrivalRadius: "movement_world",
townNpcVisitChance: "town_npc",
townNpcRollMinMs: "town_npc",
townNpcRollMaxMs: "town_npc",
townNpcRetryMs: "town_npc",
townNpcPauseMs: "town_npc",
townNpcLogIntervalMs: "town_npc",
wanderingMerchantPromptTimeoutMs: "merchant",
merchantCostBase: "merchant",
merchantCostPerLevel: "merchant",
merchantTownAutoSellShare: "merchant",
monsterEncounterWeightBase: "encounter_weights",
monsterEncounterWeightWildBonus: "encounter_weights",
merchantEncounterWeightBase: "encounter_weights",
merchantEncounterWeightRoadBonus: "encounter_weights",
adventureStartChance: "excursion",
adventureCooldownMs: "excursion",
adventureOutDurationMs: "excursion",
adventureWildMinMs: "excursion",
adventureWildMaxMs: "excursion",
adventureReturnDurationMs: "excursion",
adventureDepthWorldUnits: "excursion",
adventureEncounterCooldownMs: "excursion",
adventureReturnEncounterEnabled: "excursion",
lowHpThreshold: "roadside_hp_rest",
roadsideRestExitHp: "roadside_hp_rest",
adventureRestTargetHp: "roadside_hp_rest",
roadsideRestMinMs: "roadside_hp_rest",
roadsideRestMaxMs: "roadside_hp_rest",
roadsideRestHpPerSecond: "roadside_hp_rest",
adventureRestHpPerSecond: "roadside_hp_rest",
lootChanceCommon: "loot_rarity",
lootChanceUncommon: "loot_rarity",
lootChanceRare: "loot_rarity",
lootChanceEpic: "loot_rarity",
lootChanceLegendary: "loot_rarity",
goldLootScale: "loot_extra",
potionDropChance: "loot_extra",
equipmentDropBase: "loot_extra",
luckBuffMultiplier: "loot_extra",
goldCommonMin: "gold_tiers",
goldCommonMax: "gold_tiers",
goldUncommonMin: "gold_tiers",
goldUncommonMax: "gold_tiers",
goldRareMin: "gold_tiers",
goldRareMax: "gold_tiers",
goldEpicMin: "gold_tiers",
goldEpicMax: "gold_tiers",
goldLegendaryMin: "gold_tiers",
goldLegendaryMax: "gold_tiers",
autoSellCommon: "autosell",
autoSellUncommon: "autosell",
autoSellRare: "autosell",
autoSellEpic: "autosell",
autoSellLegendary: "autosell",
restEncounterCooldownMs: "postfight_rest",
restEncounterNpcChance: "postfight_rest",
npcCostHeal: "npc_costs",
npcCostPotion: "npc_costs",
npcCostNearbyRadius: "npc_costs",
combatDamageScale: "hero_combat",
combatDamageRollMin: "hero_combat",
combatDamageRollMax: "hero_combat",
heroCritChanceCap: "hero_combat",
heroBlockChancePerDefense: "hero_combat",
heroBlockChanceCap: "hero_combat",
minAttackIntervalMs: "hero_combat",
combatPaceMultiplier: "hero_combat",
agilityCoef: "hero_combat",
maxAttackSpeed: "hero_combat",
minAttackSpeed: "hero_combat",
enemyAttackIntervalMultiplier: "enemy_combat",
enemyCombatDamageScale: "enemy_combat",
enemyCombatDamageRollMin: "enemy_combat",
enemyCombatDamageRollMax: "enemy_combat",
enemyDodgeChance: "enemy_combat",
enemyCriticalMinChance: "enemy_combat",
enemyCritChanceCap: "enemy_combat",
enemyBurstEveryN: "enemy_combat",
enemyBurstMultiplier: "enemy_combat",
enemyChainEveryN: "enemy_combat",
enemyChainMultiplier: "enemy_combat",
debuffProcBurn: "debuff_proc",
debuffProcPoison: "debuff_proc",
debuffProcSlow: "debuff_proc",
debuffProcStun: "debuff_proc",
debuffProcFreeze: "debuff_proc",
debuffProcIceSlow: "debuff_proc",
enemyRegenDefault: "enemy_special",
enemyRegenSkeletonKing: "enemy_special",
enemyRegenForestWarden: "enemy_special",
enemyRegenBattleLizard: "enemy_special",
summonCycleSeconds: "enemy_special",
summonDamageDivisor: "enemy_special",
potionHealPercent: "potion_revive",
potionAutoUseThreshold: "potion_revive",
reviveHpPercent: "potion_revive",
autoReviveAfterMs: "potion_revive",
xpCurveEarlyBase: "xp_curve",
xpCurveEarlyScale: "xp_curve",
xpCurveMidBase: "xp_curve",
xpCurveMidScale: "xp_curve",
xpCurveLateBase: "xp_curve",
xpCurveLateScale: "xp_curve",
levelUpHpEvery: "levelup",
levelUpAtkEvery: "levelup",
levelUpDefEvery: "levelup",
levelUpStrEvery: "levelup",
levelUpConEvery: "levelup",
levelUpAgiEvery: "levelup",
levelUpLuckEvery: "levelup",
ilvlFactorSlope: "gear_scaling",
rarityMultiplierCommon: "gear_scaling",
rarityMultiplierUncommon: "gear_scaling",
rarityMultiplierRare: "gear_scaling",
rarityMultiplierEpic: "gear_scaling",
rarityMultiplierLegendary: "gear_scaling",
rollIlvlEliteBaseChance: "gear_scaling",
rollIlvlElitePlusOneChance: "gear_scaling",
autoEquipThreshold: "inventory_meta",
buffChargePeriodMs: "buff_quota",
freeBuffActivationsPerPeriod: "buff_quota",
subscriptionDurationMs: "monetization",
subscriptionWeeklyPriceRub: "monetization",
buffRefillPriceRub: "monetization",
resurrectionRefillPriceRub: "monetization",
maxRevivesFree: "monetization",
maxRevivesSubscriber: "monetization",
enemyScaleBandHp: "enemy_scale",
enemyScaleOvercapHp: "enemy_scale",
enemyScaleBandAtk: "enemy_scale",
enemyScaleOvercapAtk: "enemy_scale",
enemyScaleBandDef: "enemy_scale",
enemyScaleOvercapDef: "enemy_scale",
enemyScaleBandXp: "enemy_scale",
enemyScaleOvercapXp: "enemy_scale",
enemyScaleBandGold: "enemy_scale",
enemyScaleOvercapGold: "enemy_scale",
lootHistoryLimit: "inventory_meta"
};
function runtimeSegmentKey(path) {
if (!path) return "";
return path.includes(".") ? path.slice(path.lastIndexOf(".") + 1) : path;
}
function runtimeGroupIdForPath(path) {
const seg = runtimeSegmentKey(path);
return RUNTIME_KEY_TO_GROUP[seg] || "other";
}
function runtimeConstantRowHtml(r) {
const tip = runtimeTooltipForPath(r.path);
return `
<tr title="${e(tip)}">
<td><kbd title="${e(tip)}">${e(r.path)}</kbd></td>
<td><input data-runtime-path="${e(r.path)}" title="${e(tip)}" value="${e(formatRuntimeInputValue(r.value))}" /></td>
</tr>`;
}
function runtimeConstantsGroupedHtml(allRows) {
const buckets = new Map();
for (const g of RUNTIME_CONSTANT_GROUPS_ORDER) buckets.set(g.id, []);
for (const r of allRows) {
const gid = runtimeGroupIdForPath(r.path);
if (!buckets.has(gid)) buckets.set(gid, []);
buckets.get(gid).push(r);
}
const parts = [];
for (const g of RUNTIME_CONSTANT_GROUPS_ORDER) {
const rows = buckets.get(g.id);
if (!rows || !rows.length) continue;
rows.sort((a, b) => a.path.localeCompare(b.path));
const body = rows.map(runtimeConstantRowHtml).join("") ||
`<tr><td colspan="2" class="muted">Пусто</td></tr>`;
parts.push(`<div class="runtime-const-group">
<h4 class="runtime-const-group-title">${e(g.title)} <span class="muted">(${rows.length})</span></h4>
<table class="table">
<thead><tr><th>Key</th><th>Value</th></tr></thead>
<tbody>${body}</tbody>
</table>
</div>`);
}
return parts.join("") || `<p class="muted">Nothing to show; check API <kbd>runtime-config</kbd>.</p>`;
}
function runtimeTooltipForPath(path) {
if (!path) return "";
if (RUNTIME_CONSTANT_TOOLTIPS[path]) return RUNTIME_CONSTANT_TOOLTIPS[path];
const last = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1) : path;
if (RUNTIME_CONSTANT_TOOLTIPS[last]) return RUNTIME_CONSTANT_TOOLTIPS[last];
return "Дополнительный ключ в JSON payload; описание в коде не задано.";
}
function setPath(target, path, value) {
const parts = path.split(".");
let cur = target;
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]] || typeof cur[parts[i]] !== "object") cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
function parseLiteral(raw) {
const v = raw.trim();
if (v === "true") return true;
if (v === "false") return false;
if (v === "null") return null;
if (v !== "" && !Number.isNaN(Number(v))) return Number(v);
return raw;
}
async function loadServer() {
const [info, engine] = await Promise.all([api("info"), api("engine/status")]);
state.serverInfo = info; state.engine = engine; render();
}
function toggleLiveSnapshotOpen(ev) {
if (ev) ev.preventDefault();
state._liveSnapshotOpen = !state._liveSnapshotOpen;
const det = document.getElementById("hero-live-snapshot-details");
if (det) {
if (state._liveSnapshotOpen) det.setAttribute("open", "");
else det.removeAttribute("open");
return;
}
render();
}
function toggleHeroQuestWorldOpen(ev) {
if (ev) ev.preventDefault();
state._heroQuestWorldOpen = !state._heroQuestWorldOpen;
render();
}
function toggleHeroSearchOpen(ev) {
if (ev) ev.preventDefault();
state._heroSearchOpen = !state._heroSearchOpen;
render();
}
async function pauseServerTime() {
await api("time/pause", { method: "POST", body: "{}" });
await loadServer();
setMessage("Время на сервере приостановлено");
}
async function resumeServerTime() {
await api("time/resume", { method: "POST", body: "{}" });
await loadServer();
setMessage("Время на сервере возобновлено");
}
async function searchHeroes() {
const q = document.getElementById("hero-query")?.value || "";
const data = await api(`heroes?limit=50&offset=0&query=${encodeURIComponent(q)}`);
state.heroes = data.heroes || [];
render();
}
/** Same API as Heroes tab search; fills combatSimHeroRows for the combat simulator picker. */
async function searchHeroesForCombatSim() {
const el = document.getElementById("combat-sim-hero-query");
const q = (el && el.value != null ? el.value : state.combatSimForm.heroQuery || "").trim();
state.combatSimForm.heroQuery = q;
const data = await api(`heroes?limit=50&offset=0&query=${encodeURIComponent(q)}`);
state.combatSimHeroRows = data.heroes || [];
render();
}
async function loadRecentHeroesForCombatSim() {
state.combatSimForm.heroQuery = "";
const data = await api(`heroes?limit=50&offset=0&query=`);
state.combatSimHeroRows = data.heroes || [];
render();
}
function selectCombatSimHero(id) {
const row = (state.combatSimHeroRows || []).find(h => Number(h.id) === Number(id));
state.combatSimForm.heroId = String(id);
state.combatSimForm.heroPickName = row ? String(row.name || "") : "";
render();
}
function applyCombatSimEnemyFilter() {
const el = document.getElementById("combat-sim-enemy-filter");
if (el) state.combatSimForm.enemyFilter = el.value.trim();
render();
}
function onCombatSimEnemyTypeChange(value) {
state.combatSimForm.enemyType = value || "";
render();
}
function stopHeroMovementPoll() {
if (state._heroPollTimer) clearInterval(state._heroPollTimer);
state._heroPollTimer = null;
state._heroPollUntil = null;
}
/** @param {{ preserveJsonViewerPaths?: boolean }} [opts] — preserve expansion when reconnecting live WS without changing hero */
function stopHeroLiveWS(opts) {
if (state._heroLiveWs) {
try { state._heroLiveWs.close(); } catch (err) {}
}
state._heroLiveWs = null;
state._heroLiveWsHeroId = null;
state._heroLiveWsStatus = "disconnected";
state._heroLiveWsError = "";
state._heroLiveWsLastAt = null;
state._heroLiveSnapshot = null;
if (!opts || !opts.preserveJsonViewerPaths) {
state._jsonViewerOpenPaths = null;
}
render();
}
function startHeroMovementPoll(durationSec = 55) {
if (state._heroLiveWs && state._heroLiveWs.readyState === WebSocket.OPEN) {
return;
}
stopHeroMovementPoll();
state._heroPollUntil = Date.now() + durationSec * 1000;
state._heroPollTimer = setInterval(async () => {
if (!state.selectedHeroId) { stopHeroMovementPoll(); if (!hasOpenContentEditor()) render(); return; }
if (Date.now() > state._heroPollUntil) { stopHeroMovementPoll(); if (!hasOpenContentEditor()) render(); return; }
try {
const hero = await api(`heroes/${state.selectedHeroId}`);
state.selectedHero = hero;
if (!hasOpenContentEditor()) {
if (patchHeroLiveDom({ updateJsonTree: false })) return;
render();
}
} catch (err) {
stopHeroMovementPoll();
setMessage(String(err.message || err));
render();
}
}, 1000);
render();
}
function connectHeroLiveWS() {
if (!state.selectedHeroId) { setMessage("Select hero first"); return; }
if (!state.auth.username || !state.auth.password) { setMessage("Set admin credentials first"); return; }
stopHeroMovementPoll();
stopHeroLiveWS({ preserveJsonViewerPaths: true });
const proto = location.protocol === "https:" ? "wss" : "ws";
const auth = btoa(`${state.auth.username}:${state.auth.password}`);
const url = `${proto}://${location.host}/admin-ws/hero/${state.selectedHeroId}?auth=${encodeURIComponent(auth)}`;
const ws = new WebSocket(url);
state._heroLiveWs = ws;
state._heroLiveWsHeroId = state.selectedHeroId;
state._heroLiveWsStatus = "connecting";
state._heroLiveWsError = "";
state._heroLiveWsLastAt = null;
state._heroLiveSnapshot = null;
if (state._jsonViewerOpenPaths == null) state._jsonViewerOpenPaths = Object.create(null);
render();
ws.onopen = () => {
state._heroLiveWsStatus = "connected";
if (!hasOpenContentEditor() && patchHeroLiveDom({ updateJsonTree: false })) return;
render();
};
ws.onclose = () => {
state._heroLiveWsStatus = "disconnected";
if (!hasOpenContentEditor() && patchHeroLiveDom({ updateJsonTree: false })) return;
render();
};
ws.onerror = () => {
state._heroLiveWsStatus = "error";
state._heroLiveWsError = "WebSocket error";
if (!hasOpenContentEditor() && patchHeroLiveDom({ updateJsonTree: false })) return;
render();
};
ws.onmessage = (evt) => {
try {
const data = JSON.parse(evt.data);
if (data && data.error) {
state._heroLiveWsStatus = "error";
state._heroLiveWsError = String(data.error);
if (!hasOpenContentEditor()) {
if (patchHeroLiveDom({ updateJsonTree: false })) return;
}
render();
return;
}
let hero = null;
let snap = null;
if (data && data.hero && typeof data.hero === "object" && data.hero.id != null) {
hero = data.hero;
snap = data;
} else if (data && data.id != null) {
hero = data;
snap = { hero: data, heroMove: null };
}
if (hero && state.selectedHeroId === hero.id) {
state.selectedHero = hero;
state._heroLiveSnapshot = snap;
state._heroLiveWsLastAt = Date.now();
if (!hasOpenContentEditor()) {
if (patchHeroLiveDom({ updateJsonTree: true })) return;
render();
}
}
} catch (err) {
state._heroLiveWsStatus = "error";
state._heroLiveWsError = "Failed to parse WS payload";
if (!hasOpenContentEditor()) {
if (patchHeroLiveDom({ updateJsonTree: false })) return;
}
render();
}
};
}
function jsonChildPath(parent, segment) {
if (parent === "$") return `$.${segment}`;
return `${parent}.${segment}`;
}
function jsonPathAttrEnc(path) {
return encodeURIComponent(path);
}
function jsonQueryLeaf(root, path) {
return root.querySelector(`[data-jv-path="${jsonPathAttrEnc(path)}"]`);
}
function jsonQuerySummary(root, path) {
return root.querySelector(`[data-jv-summary="${jsonPathAttrEnc(path)}"]`);
}
/**
* Updates only leaf/summary text in the existing JSON tree DOM (keeps <details open> and scroll).
* @returns {boolean} false if the DOM no longer matches the snapshot shape (caller should full-rebuild).
*/
function patchJsonTreeValues(rootEl, value, path) {
if (value === null) {
const el = jsonQueryLeaf(rootEl, path);
if (!el) return false;
el.className = "jv-null";
el.textContent = "null";
return true;
}
if (value === undefined) {
const el = jsonQueryLeaf(rootEl, path);
if (!el) return false;
el.className = "jv-null";
el.textContent = "undefined";
return true;
}
const t = typeof value;
if (t === "boolean" || t === "number") {
const el = jsonQueryLeaf(rootEl, path);
if (!el) return false;
el.className = "jv-lit";
el.textContent = String(value);
return true;
}
if (t === "string") {
const el = jsonQueryLeaf(rootEl, path);
if (!el) return false;
el.className = "jv-str";
el.textContent = `"${value}"`;
return true;
}
if (Array.isArray(value)) {
if (value.length === 0) {
const el = jsonQueryLeaf(rootEl, path);
return !!(el && el.classList && el.classList.contains("jv-empty"));
}
const sum = jsonQuerySummary(rootEl, path);
if (!sum) return false;
sum.textContent = `[${value.length} эл.]`;
for (let i = 0; i < value.length; i++) {
if (!patchJsonTreeValues(rootEl, value[i], `${path}[${i}]`)) return false;
}
return true;
}
if (t === "object") {
const keys = Object.keys(value);
if (keys.length === 0) {
const el = jsonQueryLeaf(rootEl, path);
return !!(el && el.classList && el.classList.contains("jv-empty"));
}
const sum = jsonQuerySummary(rootEl, path);
if (!sum) return false;
sum.textContent = `{${keys.length} полей}`;
keys.sort();
for (const k of keys) {
const cp = jsonChildPath(path, k);
if (!patchJsonTreeValues(rootEl, value[k], cp)) return false;
}
return true;
}
const el = jsonQueryLeaf(rootEl, path);
if (!el) return false;
el.textContent = String(value);
return true;
}
function patchOrRebuildHeroJsonTree(jsonRoot) {
const snap = state._heroLiveSnapshot;
if (!snap || typeof snap !== "object" || !jsonRoot.querySelector(".jv-root")) {
jsonRoot.innerHTML = heroSnapshotTreeHtml();
return;
}
if (!patchJsonTreeValues(jsonRoot, snap, "$")) {
jsonRoot.innerHTML = heroSnapshotTreeHtml();
}
}
function jsonViewerToggle(path, ev) {
if (ev) ev.preventDefault();
if (!state._jsonViewerOpenPaths) state._jsonViewerOpenPaths = Object.create(null);
if (state._jsonViewerOpenPaths[path]) delete state._jsonViewerOpenPaths[path];
else state._jsonViewerOpenPaths[path] = true;
const jr = document.getElementById("hero-ws-json-root");
if (jr && state.tab === "heroes" && state._heroLiveSnapshot) {
jr.innerHTML = heroSnapshotTreeHtml();
return;
}
render();
}
function jsonTreeHtml(value, path) {
const pathArg = JSON.stringify(path);
const pEnc = jsonPathAttrEnc(path);
const open = state._jsonViewerOpenPaths && state._jsonViewerOpenPaths[path];
const openAttr = open ? " open" : "";
if (value === null) return `<span class="jv-null" data-jv-path="${pEnc}">null</span>`;
if (value === undefined) return `<span class="jv-null" data-jv-path="${pEnc}">undefined</span>`;
const t = typeof value;
if (t === "boolean" || t === "number") return `<span class="jv-lit" data-jv-path="${pEnc}">${e(String(value))}</span>`;
if (t === "string") return `<span class="jv-str" data-jv-path="${pEnc}">"${e(value)}"</span>`;
if (Array.isArray(value)) {
if (value.length === 0) return `<span class="jv-empty" data-jv-path="${pEnc}">[]</span>`;
const inner = value.map((item, i) => {
const cp = `${path}[${i}]`;
return `<div class="jv-row"><span class="jv-idx">${i}:</span> ${jsonTreeHtml(item, cp)}</div>`;
}).join("");
return `<details class="jv-node"${openAttr}><summary class="jv-sum" data-jv-summary="${pEnc}" onclick="jsonViewerToggle(${pathArg}, event)">[${value.length} эл.]</summary><div class="jv-ch">${inner}</div></details>`;
}
if (t === "object") {
const keys = Object.keys(value);
if (keys.length === 0) return `<span class="jv-empty" data-jv-path="${pEnc}">{}</span>`;
keys.sort();
const inner = keys.map(k => {
const cp = jsonChildPath(path, k);
return `<div class="jv-row"><span class="jv-key">${e(k)}:</span> ${jsonTreeHtml(value[k], cp)}</div>`;
}).join("");
return `<details class="jv-node"${openAttr}><summary class="jv-sum" data-jv-summary="${pEnc}" onclick="jsonViewerToggle(${pathArg}, event)">{${keys.length} полей}</summary><div class="jv-ch">${inner}</div></details>`;
}
return `<span data-jv-path="${pEnc}">${e(String(value))}</span>`;
}
function heroSnapshotTreeHtml() {
const snap = state._heroLiveSnapshot;
if (!snap || typeof snap !== "object") {
return `<p class="muted" style="margin-top:8px">Нет данных — подключите live или дождитесь сообщения.</p>`;
}
return `<div class="jv-root">${jsonTreeHtml(snap, "$")}</div>`;
}
function formatRemainingMs(ms) {
if (ms == null || !Number.isFinite(ms)) return "—";
if (ms <= 0) return "истекло";
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}ч ${m % 60}м ${s % 60}с`;
if (m > 0) return `${m}м ${s % 60}с`;
return `${s}с`;
}
/** Одна строка: главное — секунды, рядом человекочитаемо и локальное время окончания. */
function statusCountdownLine(iso) {
if (!iso) return "—";
const end = Date.parse(iso);
if (!Number.isFinite(end)) return e(String(iso));
const ms = end - Date.now();
if (ms <= 0) return "истекло";
const sec = Math.ceil(ms / 1000);
const human = formatRemainingMs(ms);
const localEnd = new Date(end).toLocaleString();
return `ещё <strong>${sec}</strong> с <span class="muted">(${human}, конец ${e(localEnd)})</span>`;
}
function heroMovementDetailHtml(h) {
if (!h || !h.id) return "";
const live = h.adminLiveMovement;
const tp = h.townPause;
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.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.excursionKind) {
rows.push(`<div class="kv"><kbd>excursionKind (hero)</kbd><div>${e(h.excursionKind)}</div></div>`);
}
if (live && live.online) {
if (live.excursionKind) rows.push(`<div class="kv"><kbd>excursionKind (live)</kbd><div>${e(live.excursionKind)}</div></div>`);
if (live.excursionPhase) rows.push(`<div class="kv"><kbd>excursionPhase</kbd><div>${e(live.excursionPhase)}</div></div>`);
if (live.restUntil) rows.push(`<div class="kv"><kbd>отдых / restUntil</kbd><div>${statusCountdownLine(live.restUntil)}</div></div>`);
if (live.townLeaveAt) rows.push(`<div class="kv"><kbd>в городе до выхода</kbd><div>${statusCountdownLine(live.townLeaveAt)}</div></div>`);
if (live.nextTownNPCRollAt) rows.push(`<div class="kv"><kbd>след. событие NPC в городе</kbd><div>${statusCountdownLine(live.nextTownNPCRollAt)}</div></div>`);
if (live.wanderingMerchantDeadline) {
rows.push(`<div class="kv"><kbd>окно бродячего торговца</kbd><div>${statusCountdownLine(live.wanderingMerchantDeadline)}</div></div>`);
}
const tt = live.townTour;
if (tt && typeof tt === "object") {
rows.push(`<div style="grid-column:1/-1;margin-top:8px;padding-top:8px;border-top:1px solid #2a3551;font-weight:600;color:#cfe3ff">Экскурсия по городу (town tour)</div>`);
if (tt.phase) rows.push(`<div class="kv"><kbd>phase</kbd><div>${e(tt.phase)}</div></div>`);
if (tt.npcId) rows.push(`<div class="kv"><kbd>npcId</kbd><div>${e(tt.npcId)}</div></div>`);
if (tt.townTourEndsAt) rows.push(`<div class="kv"><kbd>конец пребывания в городе</kbd><div>${statusCountdownLine(tt.townTourEndsAt)}</div></div>`);
if (tt.wanderNextAt) rows.push(`<div class="kv"><kbd>след. смена аттрактора</kbd><div>${statusCountdownLine(tt.wanderNextAt)}</div></div>`);
if (tt.townWelcomeUntil) rows.push(`<div class="kv"><kbd>welcome до</kbd><div>${statusCountdownLine(tt.townWelcomeUntil)}</div></div>`);
if (tt.townServiceUntil) rows.push(`<div class="kv"><kbd>service до</kbd><div>${statusCountdownLine(tt.townServiceUntil)}</div></div>`);
if (tt.townRestUntil) rows.push(`<div class="kv"><kbd>отдых в туре</kbd><div>${statusCountdownLine(tt.townRestUntil)}</div></div>`);
if (tt.townExitPending) rows.push(`<div class="kv"><kbd>выход из города</kbd><div>ожидает безопасной фазы</div></div>`);
if (tt.townTourDialogOpen) rows.push(`<div class="kv"><kbd>UI</kbd><div>NPCDialog открыт (таймеры сдвигаются)</div></div>`);
if (tt.townTourInteractionOpen) rows.push(`<div class="kv"><kbd>UI</kbd><div>interaction открыт</div></div>`);
if (tt.townTourStandX != null && tt.townTourStandY != null) {
rows.push(`<div class="kv"><kbd>stand</kbd><div>${e(tt.townTourStandX)}, ${e(tt.townTourStandY)}</div></div>`);
}
}
}
if (tp) {
if (tp.restUntil) rows.push(`<div class="kv"><kbd>отдых (из БД)</kbd><div>${e(tp.restKind || "")}: ${statusCountdownLine(tp.restUntil)}</div></div>`);
if (tp.townLeaveAt) rows.push(`<div class="kv"><kbd>выход из города (из БД)</kbd><div>${statusCountdownLine(tp.townLeaveAt)}</div></div>`);
if (tp.nextTownNPCRollAt) rows.push(`<div class="kv"><kbd>NPC в городе (из БД)</kbd><div>${statusCountdownLine(tp.nextTownNPCRollAt)}</div></div>`);
}
let pollNote = "";
if (state._heroPollTimer && state._heroPollUntil) {
const sec = Math.max(0, Math.ceil((state._heroPollUntil - Date.now()) / 1000));
pollNote = `<p class="status-ok">Авто-опрос героя каждую 1 с, ещё ~${sec} с</p>`;
}
return `<div class="card" id="hero-movement-panel" style="margin-top:10px"><h4>Путь, город, отдых</h4>${rows.join("")}${pollNote}</div>`;
}
function heroLiveWsCardHtml() {
const status = state._heroLiveWsStatus || "disconnected";
const last = state._heroLiveWsLastAt ? new Date(state._heroLiveWsLastAt).toLocaleTimeString() : "—";
const heroId = state._heroLiveWsHeroId;
const err = state._heroLiveWsError;
const openAttr = state._liveSnapshotOpen ? " open" : "";
return `
<div class="card" style="margin-top:10px">
<h4>Live snapshot (WebSocket)</h4>
<div class="kv"><kbd>status</kbd><div id="hero-ws-status">${e(status)}${heroId ? " (hero " + e(heroId) + ")" : ""}</div></div>
<div class="kv"><kbd>lastUpdate</kbd><div id="hero-ws-last">${e(last)}</div></div>
<div id="hero-ws-err" class="${err ? "status-err" : ""}">${err ? e(err) : ""}</div>
<div class="live-ws-bar">
<div class="live-ws-bar-main">
<button type="button" class="btn" onclick="connectHeroLiveWS()">Подключить</button>
<button type="button" class="btn" onclick="stopHeroLiveWS()">Отключить</button>
<details class="live-details" id="hero-live-snapshot-details"${openAttr}>
<summary onclick="toggleLiveSnapshotOpen(event)">Снимок: hero + heroMove (дерево)</summary>
<div id="hero-ws-json-root">${heroSnapshotTreeHtml()}</div>
</details>
<p class="muted" style="margin-top:8px"><kbd>/admin-ws/hero/{heroId}</kbd>, авторизация как у API.</p>
</div>
<div class="live-ws-bar-actions">
<span class="muted" style="font-size:12px">Сервер (тик движка)</span>
<button type="button" class="btn warn" onclick="withAction(pauseServerTime)">Пауза времени</button>
<button type="button" class="btn" onclick="withAction(resumeServerTime)">Возобновить время</button>
</div>
</div>
</div>`;
}
async function loadHero(heroId) {
if (!heroId) return;
if (state.selectedHeroId != null && state.selectedHeroId !== heroId) {
stopHeroMovementPoll();
stopHeroLiveWS();
}
if (Number(state.heroAdminDraftForId) !== Number(heroId)) {
clearHeroAdminDraft();
}
state.heroAdminDraftForId = heroId;
state.selectedHeroId = heroId;
const [hero, gear, quests] = await Promise.all([api(`heroes/${heroId}`), api(`heroes/${heroId}/gear`), api(`heroes/${heroId}/quests`)]);
state.selectedHero = hero; state.gear = gear; state.quests = quests;
const newTid = hero.currentTownId;
if (Number(state.townTourApproachNpcTownId) !== Number(newTid)) {
state.townTourApproachNpcs = [];
state.townTourApproachNpcTownId = null;
}
render();
}
async function loadTownTourApproachNpcs() {
const h = state.selectedHero;
if (!h || !state.selectedHeroId) {
setMessage("Сначала выберите героя");
return;
}
const townId = h.currentTownId;
if (!townId) {
setMessage("У героя нет currentTownId (не в городе?)");
return;
}
const data = await api(`quests/towns/${townId}/npcs`);
state.townTourApproachNpcs = data.npcs || [];
state.townTourApproachNpcTownId = townId;
setMessage(`NPC города #${townId}: ${state.townTourApproachNpcs.length}`);
render();
}
async function townTourApproachSelectedNpc() {
if (!state.selectedHeroId) return;
const sel = document.getElementById("town-tour-approach-npc-select");
const raw = sel && sel.value ? String(sel.value).trim() : "";
const npcId = raw ? Number(raw) : 0;
if (!npcId) {
setMessage("Выберите NPC в списке");
return;
}
await api(`heroes/${state.selectedHeroId}/town-tour-approach-npc`, {
method: "POST",
body: JSON.stringify({ npcId }),
});
await loadHero(state.selectedHeroId);
startHeroMovementPoll(60);
setMessage(`Подход к NPC #${npcId} (town tour)`);
}
async function heroAction(action, body = {}, pollMovement = false) {
if (!state.selectedHeroId) return;
await api(`heroes/${state.selectedHeroId}/${action}`, { method: "POST", body: JSON.stringify(body) });
await loadHero(state.selectedHeroId);
if (pollMovement) startHeroMovementPoll(60);
setMessage(`Action ${action} done`);
}
async function loadRuntime() { state.runtime = await api("runtime-config"); render(); }
async function saveRuntimeRows() {
if (!state.runtime) return;
const rows = Array.from(document.querySelectorAll("[data-runtime-path]"));
const payload = JSON.parse(JSON.stringify(state.runtime.payload || {}));
for (const row of rows) setPath(payload, row.dataset.runtimePath, parseLiteral(row.value));
await api("runtime-config", { method: "POST", body: JSON.stringify(payload) });
await loadRuntime();
setMessage("Runtime config saved and reloaded");
}
async function reloadRuntimeOnly() { await api("runtime-config/reload", { method: "POST", body: "{}" }); await loadRuntime(); setMessage("Runtime config reloaded"); }
async function loadBuffDebuff() {
state.buffDebuff = await api("buff-debuff-config");
render();
}
async function saveBuffDebuffCatalog() {
const buffs = {};
document.querySelectorAll("[data-bd-kind='buff']").forEach(el => {
const key = el.dataset.bdKey;
const field = el.dataset.bdField;
if (!buffs[key]) buffs[key] = {};
buffs[key][field] = parseLiteral(el.value);
});
const debuffs = {};
document.querySelectorAll("[data-bd-kind='debuff']").forEach(el => {
const key = el.dataset.bdKey;
const field = el.dataset.bdField;
if (!debuffs[key]) debuffs[key] = {};
debuffs[key][field] = parseLiteral(el.value);
});
await api("buff-debuff-config", { method: "POST", body: JSON.stringify({ buffs, debuffs }) });
await loadBuffDebuff();
setMessage("Buff/debuff catalog saved and reloaded");
}
async function reloadBuffDebuffOnly() {
await api("buff-debuff-config/reload", { method: "POST", body: "{}" });
await loadBuffDebuff();
setMessage("Buff/debuff catalog reloaded");
}
async function loadPayments() {
const heroId = document.getElementById("payments-hero-id")?.value || "";
const q = heroId ? `?heroId=${encodeURIComponent(heroId)}&limit=100&offset=0` : "?limit=100&offset=0";
const data = await api(`payments${q}`);
state.payments = data.payments || [];
state.paymentDetail = null;
render();
}
async function openPayment(id) { state.paymentDetail = await api(`payments/${id}`); render(); }
async function setPaymentsWebhook() {
const url = document.getElementById("webhook-url")?.value || "";
if (!url) return;
await api("payments/set-webhook", { method: "POST", body: JSON.stringify({ url }) });
setMessage("Webhook updated");
}
async function loadGearCatalog() { const data = await api("gear/catalog"); state.gearCatalog = data.catalog || []; render(); }
function gearRowsFiltered(rows, opts) {
const list = rows || [];
const { slot, rarity, subtype, catalog } = opts;
return list.filter(r => {
if (slot && r.slot !== slot) return false;
if (subtype && (r.subtype || "") !== subtype) return false;
if (!catalog && rarity && r.rarity !== rarity) return false;
return true;
});
}
function gearDistinctValues(rows, key) {
const s = new Set();
for (const r of rows || []) {
const v = r[key];
if (v != null && String(v).trim() !== "") s.add(String(v));
}
return Array.from(s).sort();
}
function gearFilterChange(which, val) {
if (which === "slot") state.gearFilterSlot = val;
if (which === "rarity") state.gearFilterRarity = val;
if (which === "subtype") state.gearFilterSubtype = val;
state.pages.gearCatalog = 1;
state.pages.gearBase = 1;
render();
}
function clearGearFilters() {
state.gearFilterSlot = state.gearFilterRarity = state.gearFilterSubtype = "";
state.pages.gearCatalog = 1;
state.pages.gearBase = 1;
render();
}
async function loadContentGearBase() { const data = await api("content/gear-base"); state.contentGearRows = data.gear || []; render(); }
async function loadContentEnemies() {
const data = await api("content/enemies");
state.contentEnemies = data.enemies || [];
render();
}
async function reloadEnemyTemplatesOnly() {
await api("content/enemies/reload", { method: "POST", body: "{}" });
setMessage("Enemy templates reloaded from DB into server memory");
}
function openContentEnemyEditorByType(type) {
const key = type == null ? "" : String(type);
const row = (state.contentEnemies || []).find(x => String(x.type) === key);
if (!row) {
setMessage("Строка не найдена: сначала «Обновить из БД»");
return;
}
state.contentEnemyEditor = Object.assign({}, row, {
_abilitiesText: Array.isArray(row.specialAbilities) ? row.specialAbilities.join(", ") : ""
});
render();
requestAnimationFrame(() => {
const el = document.getElementById("monster-editor-card");
if (el) el.scrollIntoView({ behavior: "smooth", block: "nearest" });
});
}
function closeContentEnemyEditor() { state.contentEnemyEditor = null; render(); }
async function saveContentEnemy() {
const ed = state.contentEnemyEditor;
if (!ed) {
setMessage("Сначала откройте монстра кнопкой Edit");
return;
}
const typ = String(ed.type || "").trim();
if (!typ) return;
const maxHp = Number(ed.maxHp);
const abText = ed._abilitiesText != null ? ed._abilitiesText : (Array.isArray(ed.specialAbilities) ? ed.specialAbilities.join(", ") : "");
const specialAbilities = String(abText).split(",").map(s => s.trim()).filter(Boolean);
const body = {
id: Number(ed.id) || 0,
type: typ,
name: String(ed.name || ""),
hp: Number.isFinite(maxHp) ? maxHp : 0,
maxHp: Number.isFinite(maxHp) ? maxHp : 0,
attack: Number(ed.attack) || 0,
defense: Number(ed.defense) || 0,
speed: Number(ed.speed) || 0,
critChance: Number(ed.critChance) || 0,
minLevel: Number(ed.minLevel) || 1,
maxLevel: Number(ed.maxLevel) || 100,
baseLevel: Number(ed.baseLevel) || 1,
levelVariance: Number(ed.levelVariance) || 0.3,
maxHeroLevelDiff: Number(ed.maxHeroLevelDiff) || 5,
hpPerLevel: Number(ed.hpPerLevel) || 0,
attackPerLevel: Number(ed.attackPerLevel) || 0,
defensePerLevel: Number(ed.defensePerLevel) || 0,
xpPerLevel: Number(ed.xpPerLevel) || 0,
goldPerLevel: Number(ed.goldPerLevel) || 0,
xpReward: Number(ed.xpReward) || 0,
goldReward: Number(ed.goldReward) || 0,
specialAbilities,
isElite: !!ed.isElite
};
await api(`content/enemies/${encodeURIComponent(typ)}`, { method: "PUT", body: JSON.stringify(body) });
state.contentEnemyEditor = null;
await loadContentEnemies();
setMessage("Шаблон сохранён в БД, память сервера обновлена");
}
async function runAdminCombatSim() {
const f = state.combatSimForm || {};
const heroId = Number(f.heroId || 0);
const enemyType = String(f.enemyType || "").trim();
if (!heroId || !enemyType) {
setMessage("Выберите героя в списке и архетип врага в выпадающем списке");
return;
}
const body = {
heroId,
enemyType,
enemyLevel: Number(f.enemyLevel || 0),
wallClockDelayMs: Number(f.delayMs || 0),
maxEvents: Number(f.maxEvents || 400)
};
const data = await api("engine/simulate-combat", { method: "POST", body: JSON.stringify(body) });
if (data && Array.isArray(data.events)) {
data.events = filterCombatSimEventsSkipEmptyTicks(data.events);
}
state.combatSimResult = data;
render();
}
/** Убирает подряд идущие тики с теми же HP, что у предыдущего события (шум между ударами). */
function filterCombatSimEventsSkipEmptyTicks(events) {
const list = events || [];
const out = [];
let prevH = null;
let prevE = null;
for (const ev of list) {
const h = ev.heroHp;
const e = ev.enemyHp;
if (ev.type === "tick" && prevH != null && h === prevH && e === prevE) {
continue;
}
out.push(ev);
prevH = h;
prevE = e;
}
return out;
}
function formatCombatSimLine(ev) {
const t = ev.type || "";
if (t === "tick") return `[тик] ${ev.heroHp} / ${ev.enemyHp}`;
if (t === "death") return `[смерть] ${ev.source || ""} → герой ${ev.heroHp} · враг ${ev.enemyHp}`;
if (t === "attack") {
const src = ev.source || "";
const dmg = ev.damage != null ? ev.damage : "—";
const crit = ev.isCrit ? " КРИТ" : "";
const oc = ev.outcome ? ` ${ev.outcome}` : "";
const deb = ev.debuffApplied ? ` · ${ev.debuffApplied}` : "";
return `[удар] ${src}${oc} ${dmg}${crit}${deb}${ev.heroHp} / ${ev.enemyHp}`;
}
return `[${t}] ${ev.heroHp} / ${ev.enemyHp}`;
}
function combatDamagePopupText(ev) {
if (!ev || ev.type !== "attack") return "";
const o = ev.outcome || "";
if (o === "dodge" || o === "evaded") return "Уклон";
if (o === "block") return "Блок";
if (ev.damage != null) return ev.isCrit ? `${ev.damage}!` : String(ev.damage);
return "—";
}
function combatEnemyEmoji(enemyType) {
const m = {
wolf: "🐺", boar: "🐗", zombie: "🧟", spider: "🕷️", orc: "🪓",
skeleton_archer: "🏹", battle_lizard: "🦎", fire_demon: "🔥", ice_guardian: "🧊",
skeleton_king: "💀", water_element: "💧", forest_warden: "🌲", lightning_titan: "⚡"
};
return m[String(enemyType)] || "👾";
}
function runCombatArenaShake() {
setTimeout(() => {
const arena = document.getElementById("combat-arena");
if (!arena || !arena.animate) return;
arena.animate(
[
{ transform: "translate(0,0)" },
{ transform: "translate(-6px,3px)" },
{ transform: "translate(6px,-3px)" },
{ transform: "translate(-3px,-2px)" },
{ transform: "translate(0,0)" }
],
{ duration: 160, easing: "ease-out" }
);
}, 0);
}
function spawnCombatFloat(side, text, crit, special) {
setTimeout(() => {
const arena = document.getElementById("combat-arena");
if (!arena || !text) return;
const el = document.createElement("div");
let cls = `combat-float-dmg combat-float-dmg--${side === "hero" ? "hero" : "enemy"}`;
if (crit) cls += " combat-float-dmg--crit";
if (special) cls += " combat-float-dmg--txt";
el.className = cls;
el.textContent = text;
arena.appendChild(el);
setTimeout(() => {
try { el.remove(); } catch (_) {}
}, 650);
}, 0);
}
function scrollCombatLogToEnd() {
setTimeout(() => {
const el = document.getElementById("combat-live-log-scroll");
if (el) el.scrollTop = el.scrollHeight;
}, 0);
}
function stopCombatSimLive() {
if (state._combatSimLiveTimer) {
clearTimeout(state._combatSimLiveTimer);
state._combatSimLiveTimer = null;
}
state.combatSimLive = null;
render();
}
function combatSimLiveTick() {
const live = state.combatSimLive;
if (!live || !live.open) return;
if (state._combatSimLiveTimer) {
clearTimeout(state._combatSimLiveTimer);
state._combatSimLiveTimer = null;
}
if (live.nextIdx >= live.events.length) {
live.finished = true;
live.heroHp = live.finalHeroHp;
live.enemyHp = live.finalEnemyHp;
live.stepLabel = `${live.events.length}/${live.events.length}`;
live.flashHero = false;
live.flashEnemy = false;
render();
scrollCombatLogToEnd();
return;
}
const ev = live.events[live.nextIdx];
live.heroHp = ev.heroHp;
live.enemyHp = ev.enemyHp;
live.stepLabel = `${live.nextIdx + 1}/${live.events.length}`;
if (ev.type === "attack") {
runCombatArenaShake();
const txt = combatDamagePopupText(ev);
const spec = /Уклон|Блок/.test(txt);
if (ev.source === "hero") {
live.flashEnemy = true;
live.flashHero = false;
spawnCombatFloat("enemy", txt, !!ev.isCrit, spec);
} else {
live.flashHero = true;
live.flashEnemy = false;
spawnCombatFloat("hero", txt, !!ev.isCrit, spec);
}
} else if (ev.type === "death") {
live.flashHero = false;
live.flashEnemy = false;
}
live.nextIdx++;
render();
scrollCombatLogToEnd();
state._combatSimLiveTimer = setTimeout(combatSimLiveTick, live.replayMs);
}
async function runAdminCombatSimLive() {
const f = state.combatSimForm || {};
const heroId = Number(f.heroId || 0);
const enemyType = String(f.enemyType || "").trim();
if (!heroId || !enemyType) {
setMessage("Выберите героя в списке и архетип врага в выпадающем списке");
return;
}
const replayMs = Math.max(10, Number(f.delayMs || 0) || 10);
const maxEv = Math.min(5000, Math.max(200, Number(f.maxEvents || 2500)));
const body = {
heroId,
enemyType,
enemyLevel: Number(f.enemyLevel || 0),
wallClockDelayMs: 0,
maxEvents: maxEv
};
if (state._combatSimLiveTimer) {
clearTimeout(state._combatSimLiveTimer);
state._combatSimLiveTimer = null;
}
const data = await api("engine/simulate-combat", { method: "POST", body: JSON.stringify(body) });
const evs = filterCombatSimEventsSkipEmptyTicks(data.events || []);
const eventLines = evs.map(formatCombatSimLine);
const hMax = Math.max(1, data.initialHeroMaxHp ?? 1);
const eMax = Math.max(1, data.initialEnemyMaxHp ?? 1);
state.combatSimLive = {
open: true,
events: evs,
eventLines,
replayMs,
heroMax: hMax,
enemyMax: eMax,
heroHp: data.initialHeroHp ?? 0,
enemyHp: data.initialEnemyHp ?? 0,
nextIdx: 0,
flashHero: false,
flashEnemy: false,
finished: false,
survived: data.survived,
elapsedMs: data.elapsedMs,
enemyLevel: data.enemyLevel,
enemyType: data.enemyType,
enemyName: data.enemyName || data.enemyType,
heroName: data.heroName || ("#" + data.heroId),
finalHeroHp: data.finalHeroHp,
finalEnemyHp: data.finalEnemyHp,
stepLabel: evs.length ? `0/${evs.length}` : "0/0"
};
render();
scrollCombatLogToEnd();
if (!evs.length) {
state.combatSimLive.finished = true;
render();
return;
}
state._combatSimLiveTimer = setTimeout(combatSimLiveTick, state.combatSimLive.replayMs);
}
function combatSimLiveModalHtml() {
const live = state.combatSimLive;
if (!live || !live.open) return "";
const hPct = Math.min(100, Math.round(100 * live.heroHp / live.heroMax));
const ePct = Math.min(100, Math.round(100 * live.enemyHp / live.enemyMax));
const lines = (live.eventLines || []).slice(0, live.nextIdx);
const logHtml = lines.map(line => {
let cls = "combat-feed-line";
if (line.startsWith("[тик]")) cls += " combat-feed-line--tick";
else if (line.startsWith("[удар]")) cls += " combat-feed-line--attack";
else if (line.startsWith("[смерть]")) cls += " combat-feed-line--death";
return `<div class="${cls}">${e(line)}</div>`;
}).join("");
const status = live.finished
? (live.survived ? "Победа героя" : "Поражение") + ` · ${e(live.elapsedMs)} ms симуляции`
: "Воспроизведение…";
const em = combatEnemyEmoji(live.enemyType);
const ph = live.flashHero ? " combat-portrait--flash-hero" : "";
const pe = live.flashEnemy ? " combat-portrait--flash-enemy" : "";
const endBanner = live.finished
? `<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;z-index:6;background:rgba(0,0,0,.35);font-size:clamp(22px,5vw,36px);font-weight:900;letter-spacing:2px;text-shadow:0 3px 12px rgba(0,0,0,.9);color:${live.survived ? "#7de29f" : "#ff8f8f"}">${live.survived ? "ПОБЕДА" : "ПОРАЖЕНИЕ"}</div>`
: "";
return `<div class="modal-backdrop" style="z-index:50" onclick="if(event.target===this)stopCombatSimLive()">
<div class="modal combat-live-modal" onclick="event.stopPropagation()">
<div class="combat-live-head">
<h3>Бой (как в игре)</h3>
<p class="muted" style="margin:4px 0 0 0">${e(live.heroName)} <span style="opacity:.7">против</span> ${e(live.enemyName)} · <kbd>${e(live.enemyType)}</kbd> ур.${e(live.enemyLevel)} · шаг ${e(live.stepLabel)} · пауза ${e(live.replayMs)} ms</p>
</div>
<div class="combat-live-body">
<div class="combat-arena-wrap">
<div id="combat-arena" class="combat-arena">
${endBanner}
<div class="combat-arena-inner">
<div class="combat-side combat-side--hero">
<div class="combat-portrait combat-portrait--hero${ph}">🛡️</div>
<div class="combat-portrait-label">${e(live.heroName)}</div>
<div class="combat-portrait-sub">Герой</div>
<div class="combat-arena-hp">
<div class="combat-hp-track"><div class="combat-hp-fill hero" style="width:${hPct}%"></div></div>
<div class="combat-hp-meta">${e(live.heroHp)} / ${e(live.heroMax)} HP</div>
</div>
</div>
<div class="combat-side combat-side--enemy">
<div class="combat-portrait combat-portrait--enemy${pe}">${em}</div>
<div class="combat-portrait-label">${e(live.enemyName)}</div>
<div class="combat-portrait-sub">Враг</div>
<div class="combat-arena-hp">
<div class="combat-hp-track"><div class="combat-hp-fill enemy" style="width:${ePct}%"></div></div>
<div class="combat-hp-meta" style="text-align:right">${e(live.enemyHp)} / ${e(live.enemyMax)} HP</div>
</div>
</div>
</div>
</div>
<div class="muted" style="padding:8px 14px 10px 14px;font-size:12px;border-top:1px solid #2a3551">${e(status)}</div>
</div>
<div class="combat-log-panel">
<div class="combat-log-panel-title">Журнал боя</div>
<div id="combat-live-log-scroll" class="combat-live-log-scroll">${logHtml || '<div class="combat-feed-line combat-feed-line--tick">…</div>'}</div>
</div>
</div>
<div class="combat-live-foot">
<span class="muted" style="font-size:11px;max-width:70%">Полосы HP, вспышка портрета и числа урона как в бою; справа — полный лог (включая тики).</span>
<button type="button" class="btn" onclick="stopCombatSimLive()">Закрыть</button>
</div>
</div>
</div>`;
}
async function loadContentQuests() { const data = await api("content/quests"); state.contentQuests = data.quests || []; render(); }
function openNewContentGearEditor() {
state.contentGearEditor = {
id: 0, slot: "main_hand", formId: "", name: "", subtype: "", rarity: "common", ilvl: 1,
basePrimary: 0, primaryStat: 0, statType: "mixed", speedModifier: 1, critChance: 0,
agilityBonus: 0, setName: "", specialEffect: ""
};
render();
}
function openContentGearEditorById(id) {
const g = (state.contentGearRows || []).find(x => Number(x.id) === Number(id));
if (!g) return;
state.contentGearEditor = Object.assign({}, g);
render();
}
function closeContentGearEditor() { state.contentGearEditor = null; render(); }
async function saveContentGear() {
const id = Number(document.getElementById("ce-id")?.value || 0);
const body = {
slot: document.getElementById("ce-slot").value,
formId: document.getElementById("ce-formId").value,
name: document.getElementById("ce-name").value,
subtype: document.getElementById("ce-subtype").value,
rarity: document.getElementById("ce-rarity").value,
ilvl: Number(document.getElementById("ce-ilvl").value || 1),
basePrimary: Number(document.getElementById("ce-basePrimary").value || 0),
primaryStat: Number(document.getElementById("ce-primaryStat").value || 0),
statType: document.getElementById("ce-statType").value,
speedModifier: Number(document.getElementById("ce-speedModifier").value || 1),
critChance: Number(document.getElementById("ce-critChance").value || 0),
agilityBonus: Number(document.getElementById("ce-agilityBonus").value || 0),
setName: document.getElementById("ce-setName").value,
specialEffect: document.getElementById("ce-specialEffect").value
};
if (id) await api(`content/gear/${id}`, { method: "PUT", body: JSON.stringify(body) });
else await api("content/gear", { method: "POST", body: JSON.stringify(body) });
state.contentGearEditor = null;
await loadContentGearBase();
setMessage(id ? "Gear saved" : "Gear created");
}
function openNewContentQuestEditor() {
state.contentQuestEditor = {
id: 0, npcId: 1, title: "", description: "", type: "kill_count", targetCount: 1,
targetEnemyType: null, targetTownId: null, dropChance: 0.3,
minLevel: 1, maxLevel: 100, rewardXp: 0, rewardGold: 0, rewardPotions: 0
};
render();
}
function openContentQuestEditorById(id) {
const q = (state.contentQuests || []).find(x => Number(x.id) === Number(id));
if (!q) return;
state.contentQuestEditor = Object.assign({}, q, {
targetEnemyType: q.targetEnemyType != null ? q.targetEnemyType : "",
targetTownId: q.targetTownId != null ? q.targetTownId : ""
});
render();
}
function closeContentQuestEditor() { state.contentQuestEditor = null; render(); }
async function saveContentQuest() {
const id = Number(document.getElementById("cq-id")?.value || 0);
const te = document.getElementById("cq-targetEnemy").value.trim();
const tt = document.getElementById("cq-targetTown").value.trim();
const body = {
npcId: Number(document.getElementById("cq-npcId").value),
title: document.getElementById("cq-title").value,
description: document.getElementById("cq-description").value,
type: document.getElementById("cq-type").value,
targetCount: Number(document.getElementById("cq-targetCount").value || 1),
targetEnemyType: te === "" ? null : te,
targetTownId: tt === "" ? null : Number(tt),
dropChance: Number(document.getElementById("cq-dropChance").value || 0),
minLevel: Number(document.getElementById("cq-minLevel").value || 1),
maxLevel: Number(document.getElementById("cq-maxLevel").value || 100),
rewardXp: Number(document.getElementById("cq-rewardXp").value || 0),
rewardGold: Number(document.getElementById("cq-rewardGold").value || 0),
rewardPotions: Number(document.getElementById("cq-rewardPotions").value || 0)
};
if (id) await api(`content/quests/${id}`, { method: "PUT", body: JSON.stringify(body) });
else await api("content/quests", { method: "POST", body: JSON.stringify(body) });
state.contentQuestEditor = null;
await loadContentQuests();
setMessage(id ? "Quest saved" : "Quest created");
}
function contentGearEditorHtml() {
const ed = state.contentGearEditor;
if (!ed) return "";
const isNew = !ed.id;
return `
<div class="card">
<h4>${isNew ? "New gear row" : "Edit gear #" + ed.id}</h4>
<p class="muted">Writes to PostgreSQL table <kbd>gear</kbd>. Hero equip/inventory is on the Heroes tab.</p>
<input type="hidden" id="ce-id" value="${ed.id || ""}" />
<div class="row-2">
<div><label>slot</label><input id="ce-slot" value="${e(ed.slot)}" /></div>
<div><label>formId</label><input id="ce-formId" value="${e(ed.formId)}" /></div>
</div>
<div class="row-2">
<div><label>name</label><input id="ce-name" value="${e(ed.name)}" /></div>
<div><label>subtype</label><input id="ce-subtype" value="${e(ed.subtype)}" /></div>
</div>
<div class="row-2">
<div><label>rarity</label><select id="ce-rarity">${["common","uncommon","rare","epic","legendary"].map(r => `<option ${ed.rarity===r?"selected":""}>${r}</option>`).join("")}</select></div>
<div><label>ilvl</label><input id="ce-ilvl" type="number" value="${e(ed.ilvl)}" /></div>
</div>
<div class="row-2">
<div><label>basePrimary</label><input id="ce-basePrimary" type="number" value="${e(ed.basePrimary)}" /></div>
<div><label>primaryStat</label><input id="ce-primaryStat" type="number" value="${e(ed.primaryStat)}" /></div>
</div>
<div class="row-2">
<div><label>statType</label><input id="ce-statType" value="${e(ed.statType)}" /></div>
<div><label>speedModifier</label><input id="ce-speedModifier" type="number" step="any" value="${e(ed.speedModifier)}" /></div>
</div>
<div class="row-2">
<div><label>critChance</label><input id="ce-critChance" type="number" step="any" value="${e(ed.critChance)}" /></div>
<div><label>agilityBonus</label><input id="ce-agilityBonus" type="number" value="${e(ed.agilityBonus)}" /></div>
</div>
<div class="row-2">
<div><label>setName</label><input id="ce-setName" value="${e(ed.setName)}" /></div>
<div><label>specialEffect</label><input id="ce-specialEffect" value="${e(ed.specialEffect)}" /></div>
</div>
<button class="btn" onclick="withAction(saveContentGear)">${isNew ? "Create" : "Save"}</button>
<button class="btn" onclick="closeContentGearEditor()">Cancel</button>
</div>`;
}
function contentQuestEditorHtml() {
const ed = state.contentQuestEditor;
if (!ed) return "";
const isNew = !ed.id;
const types = ["kill_count", "visit_town", "collect_item"];
const typeOpts = types.map(t => `<option value="${t}" ${ed.type === t ? "selected" : ""}>${t}</option>`).join("");
return `
<div class="card">
<h4>${isNew ? "New quest template" : "Edit quest #" + ed.id}</h4>
<p class="muted">Writes to <kbd>quests</kbd>. To give a quest to a player, use Heroes tab.</p>
<input type="hidden" id="cq-id" value="${ed.id || ""}" />
<div class="row-2">
<div><label>npcId</label><input id="cq-npcId" type="number" value="${e(ed.npcId)}" /></div>
<div><label>type</label><select id="cq-type">${typeOpts}</select></div>
</div>
<div><label>title</label><input id="cq-title" value="${e(ed.title)}" /></div>
<div><label>description</label><input id="cq-description" value="${e(ed.description)}" /></div>
<div class="row-2">
<div><label>targetCount</label><input id="cq-targetCount" type="number" value="${e(ed.targetCount)}" /></div>
<div><label>dropChance</label><input id="cq-dropChance" type="number" step="any" value="${e(ed.dropChance)}" /></div>
</div>
<div class="row-2">
<div><label>targetEnemyType (optional)</label><input id="cq-targetEnemy" value="${e(ed.targetEnemyType)}" placeholder="empty = any" /></div>
<div><label>targetTownId (optional)</label><input id="cq-targetTown" type="number" value="${ed.targetTownId != null && ed.targetTownId !== "" ? e(ed.targetTownId) : ""}" placeholder="visit_town" /></div>
</div>
<div class="row-2">
<div><label>minLevel</label><input id="cq-minLevel" type="number" value="${e(ed.minLevel)}" /></div>
<div><label>maxLevel</label><input id="cq-maxLevel" type="number" value="${e(ed.maxLevel)}" /></div>
</div>
<div class="row-2">
<div><label>rewardXp</label><input id="cq-rewardXp" type="number" value="${e(ed.rewardXp)}" /></div>
<div><label>rewardGold</label><input id="cq-rewardGold" type="number" value="${e(ed.rewardGold)}" /></div>
</div>
<div><label>rewardPotions</label><input id="cq-rewardPotions" type="number" value="${e(ed.rewardPotions)}" /></div>
<button class="btn" onclick="withAction(saveContentQuest)">${isNew ? "Create" : "Save"}</button>
<button class="btn" onclick="closeContentQuestEditor()">Cancel</button>
</div>`;
}
function contentEnemyEditorHtml() {
const ed = state.contentEnemyEditor;
if (!ed) return "";
const abHint = ADMIN_ENEMY_ABILITIES.join(", ");
const abVal = ed._abilitiesText != null ? ed._abilitiesText : (Array.isArray(ed.specialAbilities) ? ed.specialAbilities.join(", ") : "");
return `
<div class="card" id="monster-editor-card">
<h4>Редактирование шаблона</h4>
<p class="muted">Таблица <kbd>enemies</kbd>. Ключ <kbd>type</kbd> не меняется. Кнопка ниже пишет в PostgreSQL и обновляет шаблоны в памяти процесса.</p>
<input type="hidden" id="me-id" value="${e(ed.id)}" />
<div class="row-2">
<div><label>type (read-only)</label><input id="me-type" readonly value="${e(ed.type)}" tabindex="-1" /></div>
<div><label>name</label><input id="me-name" value="${e(ed.name)}" autocomplete="off" oninput="patchMonsterField('name', this.value)" /></div>
</div>
<div class="row-2">
<div><label>maxHp</label><input id="me-maxHp" type="number" value="${e(ed.maxHp)}" oninput="patchMonsterField('maxHp', this.value)" /></div>
<div><label>isElite</label><label style="display:flex;align-items:center;gap:8px;margin-top:8px"><input id="me-isElite" type="checkbox" ${ed.isElite ? "checked" : ""} onchange="patchMonsterField('isElite', this.checked)" /> elite</label></div>
</div>
<div class="row-2">
<div><label>attack</label><input id="me-attack" type="number" value="${e(ed.attack)}" oninput="patchMonsterField('attack', this.value)" /></div>
<div><label>defense</label><input id="me-defense" type="number" value="${e(ed.defense)}" oninput="patchMonsterField('defense', this.value)" /></div>
</div>
<div class="row-2">
<div><label>speed</label><input id="me-speed" type="number" step="any" value="${e(ed.speed)}" oninput="patchMonsterField('speed', this.value)" /></div>
<div><label>critChance (01)</label><input id="me-critChance" type="number" step="any" value="${e(ed.critChance)}" oninput="patchMonsterField('critChance', this.value)" /></div>
</div>
<div class="row-2">
<div><label>minLevel</label><input id="me-minLevel" type="number" value="${e(ed.minLevel)}" oninput="patchMonsterField('minLevel', this.value)" /></div>
<div><label>maxLevel</label><input id="me-maxLevel" type="number" value="${e(ed.maxLevel)}" oninput="patchMonsterField('maxLevel', this.value)" /></div>
</div>
<div class="row-2">
<div><label>baseLevel</label><input id="me-baseLevel" type="number" value="${e(ed.baseLevel)}" oninput="patchMonsterField('baseLevel', this.value)" /></div>
<div><label>levelVariance (0..1)</label><input id="me-levelVariance" type="number" step="any" value="${e(ed.levelVariance)}" oninput="patchMonsterField('levelVariance', this.value)" /></div>
</div>
<div class="row-2">
<div><label>maxHeroLevelDiff</label><input id="me-maxHeroLevelDiff" type="number" value="${e(ed.maxHeroLevelDiff)}" oninput="patchMonsterField('maxHeroLevelDiff', this.value)" /></div>
<div><label>hpPerLevel</label><input id="me-hpPerLevel" type="number" step="any" value="${e(ed.hpPerLevel)}" oninput="patchMonsterField('hpPerLevel', this.value)" /></div>
</div>
<div class="row-2">
<div><label>attackPerLevel</label><input id="me-attackPerLevel" type="number" step="any" value="${e(ed.attackPerLevel)}" oninput="patchMonsterField('attackPerLevel', this.value)" /></div>
<div><label>defensePerLevel</label><input id="me-defensePerLevel" type="number" step="any" value="${e(ed.defensePerLevel)}" oninput="patchMonsterField('defensePerLevel', this.value)" /></div>
</div>
<div class="row-2">
<div><label>xpPerLevel</label><input id="me-xpPerLevel" type="number" step="any" value="${e(ed.xpPerLevel)}" oninput="patchMonsterField('xpPerLevel', this.value)" /></div>
<div><label>goldPerLevel</label><input id="me-goldPerLevel" type="number" step="any" value="${e(ed.goldPerLevel)}" oninput="patchMonsterField('goldPerLevel', this.value)" /></div>
</div>
<div class="row-2">
<div><label>xpReward</label><input id="me-xpReward" type="number" value="${e(ed.xpReward)}" oninput="patchMonsterField('xpReward', this.value)" /></div>
<div><label>goldReward</label><input id="me-goldReward" type="number" value="${e(ed.goldReward)}" oninput="patchMonsterField('goldReward', this.value)" /></div>
</div>
<div><label>specialAbilities (через запятую)</label><input id="me-abilities" value="${e(abVal)}" placeholder="burn, regen" autocomplete="off" oninput="patchMonsterField('abilities', this.value)" /></div>
<p class="muted" style="margin-top:4px">Допустимые теги: ${e(abHint)}</p>
<button type="button" class="btn" onclick="withAction(() => window.saveContentEnemy())">Сохранить в БД</button>
<button type="button" class="btn" onclick="closeContentEnemyEditor()">Отмена</button>
</div>`;
}
async function loadHeroGrantGearList() {
const raw = (document.getElementById("hero-grant-gear-q") && document.getElementById("hero-grant-gear-q").value) || "";
state.grantGearSearchQuery = raw.trim();
let lim = Number(document.getElementById("hero-grant-gear-limit") && document.getElementById("hero-grant-gear-limit").value);
if (!Number.isFinite(lim) || lim < 1) lim = 200;
lim = Math.min(5000, lim);
const p = new URLSearchParams();
if (state.grantGearSearchQuery) p.set("query", state.grantGearSearchQuery);
if (state.heroGrantFilterSlot) p.set("slot", state.heroGrantFilterSlot);
if (state.heroGrantFilterRarity) p.set("rarity", state.heroGrantFilterRarity);
if (state.heroGrantFilterSubtype) p.set("subtype", state.heroGrantFilterSubtype);
p.set("limit", String(lim));
const data = await api("content/gear-base?" + p.toString());
state.heroGrantGearCandidates = data.gear || [];
render();
}
function heroGrantFilterChange(which, val) {
if (which === "slot") state.heroGrantFilterSlot = val;
if (which === "rarity") state.heroGrantFilterRarity = val;
if (which === "subtype") state.heroGrantFilterSubtype = val;
render();
}
async function loadTeleportTowns() {
const data = await api("towns");
state.teleportTowns = data.towns || [];
render();
}
async function teleportHeroToTown() {
if (!state.selectedHeroId) return;
const tid = Number(document.getElementById("hero-teleport-town") && document.getElementById("hero-teleport-town").value);
if (!tid) { setMessage("Select a town"); return; }
await api(`heroes/${state.selectedHeroId}/teleport-town`, { method: "POST", body: JSON.stringify({ townId: tid }) });
await loadHero(state.selectedHeroId);
setMessage("Teleported to town");
}
async function grantGearFromDB(sourceGearId) {
if (!state.selectedHeroId) return;
await api(`heroes/${state.selectedHeroId}/gear/grant`, { method: "POST", body: JSON.stringify({ sourceGearId }) });
await loadHero(state.selectedHeroId);
setMessage("Gear granted (copy)");
}
async function grantGear() {
if (!state.selectedHeroId) return;
const slot = document.getElementById("grant-slot").value;
const formId = document.getElementById("grant-form-id").value.trim();
const rarity = document.getElementById("grant-rarity").value;
const ilvl = Number(document.getElementById("grant-ilvl").value || 1);
await api(`heroes/${state.selectedHeroId}/gear/grant`, { method: "POST", body: JSON.stringify({ slot, formId, rarity, ilvl }) });
await loadHero(state.selectedHeroId);
setMessage("Gear granted");
}
async function equipItem(itemId) { await api(`heroes/${state.selectedHeroId}/gear/equip`, { method: "POST", body: JSON.stringify({ itemId }) }); await loadHero(state.selectedHeroId); }
async function unequipSlot(slot) { await api(`heroes/${state.selectedHeroId}/gear/unequip`, { method: "POST", body: JSON.stringify({ slot }) }); await loadHero(state.selectedHeroId); }
async function deleteItem(itemId) { await api(`heroes/${state.selectedHeroId}/gear/${itemId}`, { method: "DELETE" }); await loadHero(state.selectedHeroId); }
async function loadQuestTowns() {
const data = await api("quests/towns");
state.questTowns = data.towns || []; state.townNpcs = []; state.npcQuests = [];
render();
}
async function selectTown(townId) {
const data = await api(`quests/towns/${townId}/npcs`);
state.townNpcs = data.npcs || []; state.npcQuests = [];
render();
}
async function selectNpc(npcId) { const data = await api(`quests/npcs/${npcId}`); state.npcQuests = data.quests || []; render(); }
async function acceptQuest(questId) {
if (!state.selectedHeroId) { setMessage("Select a hero on the Heroes tab first"); return; }
await api(`heroes/${state.selectedHeroId}/quests/${questId}/accept`, { method: "POST", body: "{}" });
await loadHero(state.selectedHeroId);
}
async function claimQuest(questId) { await api(`heroes/${state.selectedHeroId}/quests/${questId}/claim`, { method: "POST", body: "{}" }); await loadHero(state.selectedHeroId); }
async function abandonQuest(questId) { await api(`heroes/${state.selectedHeroId}/quests/${questId}`, { method: "DELETE" }); await loadHero(state.selectedHeroId); }
async function applyHeroBuffAdmin() {
if (!state.selectedHeroId) { setMessage("Сначала выберите героя"); return; }
const buffType = document.getElementById("hero-admin-buff-type")?.value;
if (!buffType) { setMessage("Выберите тип баффа"); return; }
await withRowAction("hero-admin-buff", async () => {
await api(`heroes/${state.selectedHeroId}/apply-buff`, { method: "POST", body: JSON.stringify({ buffType }) });
await loadHero(state.selectedHeroId);
}, "Бафф применён");
}
async function applyHeroDebuffAdmin() {
if (!state.selectedHeroId) { setMessage("Сначала выберите героя"); return; }
const debuffType = document.getElementById("hero-admin-debuff-type")?.value;
if (!debuffType) { setMessage("Выберите тип дебаффа"); return; }
await withRowAction("hero-admin-debuff", async () => {
await api(`heroes/${state.selectedHeroId}/apply-debuff`, { method: "POST", body: JSON.stringify({ debuffType }) });
await loadHero(state.selectedHeroId);
}, "Дебафф применён");
}
function login() {
state.auth.username = document.getElementById("login-user").value.trim();
state.auth.password = document.getElementById("login-pass").value.trim();
sessionStorage.setItem("admin_user", state.auth.username);
sessionStorage.setItem("admin_pass", state.auth.password);
setMessage("Credentials saved for session");
}
function setTab(tab) {
state.tab = tab;
render();
if (tab === "constants" && !state.runtime) withAction(loadRuntime);
if (tab === "buffDebuff" && !state.buffDebuff) withAction(loadBuffDebuff);
if ((tab === "monsters" || tab === "combatSim") && (!state.contentEnemies || state.contentEnemies.length === 0)) withAction(loadContentEnemies);
}
function sectionServer() {
const info = state.serverInfo || {};
const eng = state.engine || {};
const eff = info.effective;
const effKeyCount = eff && typeof eff === "object" && !Array.isArray(eff) ? Object.keys(eff).length : 0;
const effJson = eff && typeof eff === "object" ? e(JSON.stringify(eff, null, 2)) : "";
return `
<div class="card">
<h3>Server control</h3>
<button class="btn" onclick="withAction(loadServer)">Refresh</button>
<button class="btn warn" onclick="withAction(() => api('time/pause',{method:'POST', body:'{}'}).then(loadServer))">Pause time</button>
<button class="btn" onclick="withAction(() => api('time/resume',{method:'POST', body:'{}'}).then(loadServer))">Resume time</button>
</div>
<div class="panel">
<div class="card">
<h4>Server info</h4>
<div class="kv"><kbd>version</kbd><div>${e(info.version)}</div></div>
<div class="kv"><kbd>goVersion</kbd><div>${e(info.goVersion)}</div></div>
<div class="kv"><kbd>uptimeMs</kbd><div>${e(info.uptimeMs)}</div></div>
<div class="kv"><kbd>db.totalConns</kbd><div>${e(info.dbPool?.totalConns)}</div></div>
<div class="kv"><kbd>db.idleConns</kbd><div>${e(info.dbPool?.idleConns)}</div></div>
<div class="kv"><kbd>effective</kbd><div>${effKeyCount ? effKeyCount + " keys (runtime tuning in memory)" : "—"}</div></div>
${effJson ? `<details style="margin-top:10px"><summary class="muted">effective JSON</summary><pre style="max-height:280px;overflow:auto;font-size:11px;margin:8px 0 0">${effJson}</pre></details>` : ""}
</div>
<div class="card">
<h4>Engine status</h4>
<div class="kv"><kbd>running</kbd><div>${e(eng.running)}</div></div>
<div class="kv"><kbd>tickRateMs</kbd><div>${e(eng.tickRateMs)}</div></div>
<div class="kv"><kbd>activeCombats</kbd><div>${e(eng.activeCombats)}</div></div>
<div class="kv"><kbd>activeMovements</kbd><div>${e(eng.activeMovements)}</div></div>
<div class="kv"><kbd>timePaused</kbd><div>${e(eng.timePaused)}</div></div>
</div>
</div>`;
}
function sectionHeroes() {
const h = state.selectedHero || {};
const p = paged(state.heroes, "heroes", 10);
const list = p.items.map(x => `
<div class="list-row ${x.id===state.selectedHeroId?'active':''}" onclick="withAction(() => loadHero(${x.id}))">
<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)}</span>
</div>`).join("");
let heroExtra = "";
let teleportOpts = `<option value="">— town —</option>`;
let townTourApproachPanel = "";
if (state.selectedHeroId) {
const rowsDbH = state.contentGearRows || [];
const rowsCatH = state.gearCatalog || [];
const hgSlots = Array.from(new Set([
"main_hand", "chest", "head", "feet", "neck", "hands", "legs", "cloak", "finger", "wrist",
...gearDistinctValues(rowsDbH, "slot"),
...gearDistinctValues(rowsCatH, "slot")
])).sort();
const hgSubs = Array.from(new Set([
...gearDistinctValues(rowsDbH, "subtype"),
...gearDistinctValues(rowsCatH, "subtype")
])).sort();
const tierHR = ["common", "uncommon", "rare", "epic", "legendary"];
const hgRars = tierHR.concat(gearDistinctValues(rowsDbH, "rarity").filter(r => !tierHR.includes(r)).sort());
const hgSlotOpts = `<option value="">All slots</option>` + hgSlots.map(s => `<option value="${e(s)}" ${state.heroGrantFilterSlot === s ? "selected" : ""}>${e(s)}</option>`).join("");
const hgSubOpts = `<option value="">All subtypes</option>` + hgSubs.map(s => `<option value="${e(s)}" ${state.heroGrantFilterSubtype === s ? "selected" : ""}>${e(s)}</option>`).join("");
const hgRarOpts = `<option value="">All rarities</option>` + hgRars.map(s => `<option value="${e(s)}" ${state.heroGrantFilterRarity === s ? "selected" : ""}>${e(s)}</option>`).join("");
teleportOpts = `<option value="">— town —</option>` + (state.teleportTowns || []).map(t => `<option value="${t.id}">${e(t.name)} (#${t.id})</option>`).join("");
const liveMov = h.adminLiveMovement;
const showTownTourAdmin = h.excursionKind === "town" && liveMov && liveMov.online;
const npcListApproach = state.townTourApproachNpcs || [];
const npcOptsApproach = npcListApproach.map(n =>
`<option value="${e(String(n.id))}">#${e(n.id)} ${e(n.name || "")} (${e(n.type || "")})</option>`
).join("");
townTourApproachPanel = showTownTourAdmin ? `
<div style="margin-top:12px;padding-top:12px;border-top:1px solid #2a3551">
<h4 style="margin:0 0 8px;font-size:14px;color:#cfe3ff">Экскурсия по городу</h4>
<p class="muted" style="margin:0 0 8px;font-size:12px">Только для онлайн-героя с <kbd>excursionKind=town</kbd>. Текущий город: <kbd>${e(h.currentTownId)}</kbd>. Детали — блок «Путь, город, отдых».</p>
<button type="button" class="btn" onclick="withAction(loadTownTourApproachNpcs)">Загрузить NPC текущего города</button>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<select id="town-tour-approach-npc-select" style="min-width:200px;max-width:340px"><option value="">— NPC —</option>${npcOptsApproach}</select>
<button type="button" class="btn" onclick="withAction(townTourApproachSelectedNpc)">Подойти к NPC</button>
</div>
</div>` : "";
const equipped = state.gear?.equipped || {};
const inventory = state.gear?.inventory || [];
const slotRows = Object.keys(equipped).sort().map(slot => `
<tr>
<td>${e(slot)}</td><td>${e(equipped[slot]?.name)}</td><td>${e(equipped[slot]?.rarity)}</td>
<td>
<button class="btn" onclick="openConfirm('Unequip item','Unequip slot ${e(slot)}?', () => withRowAction('gear-slot-${e(slot)}', () => unequipSlot('${e(slot)}'), 'Unequipped'))">Unequip</button>
<div class="${state.rowStatus['gear-slot-'+slot]?.ok?'status-ok':'status-err'}">${e(state.rowStatus['gear-slot-'+slot]?.message || "")}</div>
</td>
</tr>`).join("");
const invPage = paged(inventory, "gearInventory", 10);
const invRows = invPage.items.map(it => `
<tr>
<td>${e(it.id)}</td><td>${e(it.slot)}</td><td>${e(it.name)}</td><td>${e(it.rarity)}</td><td>${e(it.ilvl)}</td>
<td>
<button class="btn" onclick="withAction(() => withRowAction('gear-item-${it.id}', () => equipItem(${it.id}), 'Equipped'))">Equip</button>
<button class="btn warn" onclick="openConfirm('Delete item','Delete gear item #${it.id}?', () => withRowAction('gear-item-${it.id}', () => deleteItem(${it.id}), 'Deleted'))">Delete</button>
<div class="${state.rowStatus['gear-item-'+it.id]?.ok?'status-ok':'status-err'}">${e(state.rowStatus['gear-item-'+it.id]?.message || "")}</div>
</td>
</tr>`).join("");
const heroQuests = state.quests?.quests || [];
const heroPage = paged(heroQuests, "heroQuests", 10);
const heroRows = heroPage.items.map(q => `
<tr>
<td>${e(q.questId)}</td><td>${e(q.quest?.title)}</td><td>${e(q.status)}</td><td>${e(q.progress)}/${e(q.quest?.targetCount)}</td>
<td>
<button class="btn" onclick="withAction(() => withRowAction('hero-quest-${q.questId}', () => claimQuest(${q.questId}), 'Claimed'))">Claim</button>
<button class="btn warn" onclick="openConfirm('Abandon quest','Abandon quest #${q.questId}?', () => withRowAction('hero-quest-${q.questId}', () => abandonQuest(${q.questId}), 'Abandoned'))">Abandon</button>
<div class="${state.rowStatus['hero-quest-'+q.questId]?.ok?'status-ok':'status-err'}">${e(state.rowStatus['hero-quest-'+q.questId]?.message || "")}</div>
</td>
</tr>`).join("");
const townsPage = paged(state.questTowns, "towns", 8);
const towns = townsPage.items.map(t => `<div class="list-row" onclick="withAction(() => selectTown(${t.id}))"><strong>${e(t.name)}</strong><span>Lvl ${e(t.levelMin)}-${e(t.levelMax)}</span><span></span><span>ID ${e(t.id)}</span></div>`).join("");
const npcPage = paged(state.townNpcs, "npcs", 8);
const npcs = npcPage.items.map(n => `<div class="list-row" onclick="withAction(() => selectNpc(${n.id}))"><strong>${e(n.name)}</strong><span>${e(n.type)}</span><span></span><span>ID ${e(n.id)}</span></div>`).join("");
const tmplPage = paged(state.npcQuests, "npcQuests", 10);
const templates = tmplPage.items.map(q => `
<tr><td>${e(q.id)}</td><td>${e(q.title)}</td><td>${e(q.type)}</td><td>${e(q.rewardXp)}/${e(q.rewardGold)}</td><td><button class="btn" onclick="withAction(() => withRowAction('template-quest-${q.id}', () => acceptQuest(${q.id}), 'Accepted'))">Accept to this hero</button><div class="${state.rowStatus['template-quest-'+q.id]?.ok?'status-ok':'status-err'}">${e(state.rowStatus['template-quest-'+q.id]?.message || "")}</div></td></tr>`).join("");
heroExtra = `
<div class="card">
<h3>Gear — hero #${state.selectedHeroId}</h3>
<p class="muted">Grant, equip, inventory for the selected hero only.</p>
<button class="btn" onclick="withAction(loadGearCatalog)">Load merged catalog (reference)</button>
<button class="btn" onclick="withAction(() => loadHero(state.selectedHeroId))">Reload hero gear</button>
</div>
<div class="card">
<h4>Grant gear to inventory</h4>
<p class="muted">Table <kbd>gear</kbd>: text search and/or filters (AND). Creates a <strong>copy</strong> for this hero. Load <em>Load merged catalog</em> above to fill subtype/slot hints.</p>
<div class="row" style="align-items:end;margin-bottom:8px">
<div><label class="muted">Slot</label><select onchange="heroGrantFilterChange('slot', this.value)">${hgSlotOpts}</select></div>
<div><label class="muted">Subtype</label><select onchange="heroGrantFilterChange('subtype', this.value)">${hgSubOpts}</select></div>
<div><label class="muted">Rarity</label><select onchange="heroGrantFilterChange('rarity', this.value)">${hgRarOpts}</select></div>
</div>
<div class="row">
<div><input id="hero-grant-gear-q" placeholder="Also filter by name / formId / id…" value="${e(state.grantGearSearchQuery)}" /></div>
<div><input id="hero-grant-gear-limit" type="number" value="200" min="1" max="5000" title="Max rows" /></div>
<div><button type="button" class="btn" onclick="withAction(loadHeroGrantGearList)">Search DB</button></div>
</div>
<div style="max-height:280px;overflow:auto;margin-top:8px">
<table class="table">
<thead><tr><th>ID</th><th>Slot</th><th>Subtype</th><th>Name</th><th>Rarity</th><th>iLvl</th><th></th></tr></thead>
<tbody>${(state.heroGrantGearCandidates || []).map(g => `
<tr>
<td>${e(g.id)}</td><td>${e(g.slot)}</td><td>${e(g.subtype)}</td><td>${e(g.name)}</td><td>${e(g.rarity)}</td><td>${e(g.ilvl)}</td>
<td>
<button type="button" class="btn" onclick="withAction(() => withRowAction('grant-db-${g.id}', () => grantGearFromDB(${g.id}), 'Granted'))">Grant copy</button>
<span class="${state.rowStatus['grant-db-'+g.id]?.ok?'status-ok':'status-err'}">${e(state.rowStatus['grant-db-'+g.id]?.message || "")}</span>
</td>
</tr>`).join("") || `<tr><td colspan="7" class="muted">Click Search DB</td></tr>`}</tbody>
</table>
</div>
<details style="margin-top:12px"><summary class="muted">Advanced: grant from code catalog (slot + optional formId)</summary>
<div class="row" style="margin-top:8px">
<div><select id="grant-slot"><option value="main_hand">main_hand</option><option value="chest">chest</option><option value="head">head</option><option value="feet">feet</option><option value="neck">neck</option><option value="hands">hands</option><option value="legs">legs</option><option value="cloak">cloak</option><option value="finger">finger</option><option value="wrist">wrist</option></select></div>
<div><input id="grant-form-id" placeholder="formId optional" /></div>
<div><select id="grant-rarity"><option>common</option><option>uncommon</option><option>rare</option><option>epic</option><option>legendary</option></select></div>
</div>
<div class="row"><div><input id="grant-ilvl" type="number" value="1" /></div><div><button type="button" class="btn" onclick="withAction(() => withRowAction('grant-gear', grantGear, 'Granted'))">Grant</button><span class="${state.rowStatus['grant-gear']?.ok?'status-ok':'status-err'}">${e(state.rowStatus['grant-gear']?.message || "")}</span></div></div>
</details>
</div>
<div class="row-2">
<div class="card"><h4>Equipped</h4><table class="table"><thead><tr><th>Slot</th><th>Name</th><th>Rarity</th><th>Action</th></tr></thead><tbody>${slotRows || `<tr><td colspan="4" class="muted">No equipped items</td></tr>`}</tbody></table></div>
<div class="card"><h4>Inventory</h4><table class="table"><thead><tr><th>ID</th><th>Slot</th><th>Name</th><th>Rarity</th><th>iLvl</th><th>Actions</th></tr></thead><tbody>${invRows || `<tr><td colspan="6" class="muted">No inventory items</td></tr>`}</tbody></table>${pagerHtml("gearInventory", invPage.page, invPage.total)}</div>
</div>
<div class="card"><h4>Quest log — this hero</h4><table class="table"><thead><tr><th>QuestID</th><th>Title</th><th>Status</th><th>Progress</th><th>Actions</th></tr></thead><tbody>${heroRows || `<tr><td colspan="5" class="muted">No quests for hero</td></tr>`}</tbody></table>${pagerHtml("heroQuests", heroPage.page, heroPage.total)}</div>
<details class="card live-details"${state._heroQuestWorldOpen ? " open" : ""}>
<summary onclick="toggleHeroQuestWorldOpen(event)">Квесты из мира <span class="muted">города → NPC → шаблон для этого героя</span></summary>
<div class="quest-world-panel">
<p class="muted">Полный обзор городов и NPC есть на вкладке «Towns».</p>
<button type="button" class="btn" onclick="withAction(loadQuestTowns)">Загрузить города</button>
<button type="button" class="btn" onclick="withAction(() => loadHero(state.selectedHeroId))">Обновить квесты героя</button>
<div class="row-2" style="margin-top:12px">
<div class="card" style="margin-bottom:0"><h4>Города</h4><div class="list">${towns || `<div class="list-row"><span class="muted">Нет данных</span><span></span><span></span><span></span></div>`}</div>${pagerHtml("towns", townsPage.page, townsPage.total)}</div>
<div class="card" style="margin-bottom:0"><h4>NPC в городе</h4><div class="list">${npcs || `<div class="list-row"><span class="muted">Выберите город</span><span></span><span></span><span></span></div>`}</div>${pagerHtml("npcs", npcPage.page, npcPage.total)}</div>
</div>
<h4 style="margin:16px 0 8px">Шаблоны квестов у NPC</h4>
<table class="table"><thead><tr><th>ID</th><th>Title</th><th>Type</th><th>Rewards</th><th>Action</th></tr></thead><tbody>${templates || `<tr><td colspan="5" class="muted">Выберите NPC</td></tr>`}</tbody></table>${pagerHtml("npcQuests", tmplPage.page, tmplPage.total)}
</div>
</details>`;
}
const searchOpenAttr = state._heroSearchOpen ? " open" : "";
return `
<div class="heroes-tab-layout">
<details class="card hero-search-details"${searchOpenAttr}>
<summary onclick="toggleHeroSearchOpen(event)">Поиск героя <span class="muted">имя / id · список и пагинация</span></summary>
<div>
<div class="row">
<div><input id="hero-query" placeholder="Name (list) or numeric id (one hero)" /></div>
<div><button type="button" class="btn" onclick="withAction(searchHeroes)">Search</button></div>
<div class="muted">Found: ${state.heroes.length}</div>
</div>
<div class="list">${list || '<div class="list-row"><span class="muted">No heroes</span><span></span><span></span><span></span></div>'}</div>
${pagerHtml("heroes", p.page, p.total)}
</div>
</details>
<div class="card">
<h3>Hero details</h3>
${state.selectedHeroId ? `
<div class="hero-details-grid">
<div class="hero-details-main">
<div class="kv"><kbd>ID</kbd><div>${e(h.id)}</div></div>
<div class="kv"><kbd>Name</kbd><div>${e(h.name)}</div></div>
<div class="kv"><kbd>State</kbd><div id="hero-detail-state">${e(h.state)}</div></div>
${heroMovementDetailHtml(h)}
${heroLiveWsCardHtml()}
<div class="kv"><kbd>Telegram ID</kbd><div>${e(h.telegramId)}</div></div>
<div class="kv"><kbd>Level</kbd><div id="hero-detail-level">${e(h.level)}</div></div>
<div class="kv"><kbd>HP</kbd><div id="hero-detail-hp">${e(h.hp)}/${e(h.maxHp)}</div></div>
<div class="kv"><kbd>Gold</kbd><div id="hero-detail-gold">${e(h.gold)}</div></div>
<div class="kv"><kbd>Subscription</kbd><div id="hero-detail-subscription">${h.subscriptionActive ? "active" : "inactive"}${h.subscriptionExpiresAt ? " · until " + e(h.subscriptionExpiresAt) : ""}</div></div>
</div>
<aside class="hero-details-actions">
<h4>Управление</h4>
<div class="hero-actions-inputs">
<div><input id="hero-hp" type="number" placeholder="New HP" value="${e(state.heroAdminDraft.hp)}" oninput="setHeroAdminDraft('hp', this.value)" /></div>
<div><input id="hero-gold" type="number" placeholder="New Gold" value="${e(state.heroAdminDraft.gold)}" oninput="setHeroAdminDraft('gold', this.value)" /></div>
<div><input id="hero-level" type="number" placeholder="New Level" value="${e(state.heroAdminDraft.level)}" oninput="setHeroAdminDraft('level', this.value)" /></div>
</div>
<div class="hero-actions-btns">
<button type="button" class="btn" onclick="withAction(() => heroAction('set-hp',{hp:Number(document.getElementById('hero-hp').value)}))">Set HP</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('set-gold',{gold:Number(document.getElementById('hero-gold').value)}))">Set Gold</button>
<button type="button" class="btn" title="Сброс к уровню 1 и последовательные level-up до цели (кривая XP/статы как в игре); золото не трогаем" onclick="withAction(() => heroAction('set-level',{level:Number(document.getElementById('hero-level').value)}))">Set Level</button>
<button type="button" class="btn warn" title="Уровень 1, случайный стартовый меч и нагрудник, 100 золота, новый спавн, квесты сброшены" onclick="withAction(async () => { if (!confirm('Полный сброс героя: уровень 1, стартовый шмот, 100 золота, случайный спавн, удаление квестов. Продолжить?')) return; await heroAction('full-reset',{}); })">Полный сброс</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('revive',{}))">Revive</button>
<span class="muted">Подписка:</span>
<input id="hero-sub-periods" type="number" min="1" max="52" value="${e(state.heroAdminDraft.subPeriods || '1')}" style="width:52px" title="Число периодов (как при покупке подписки)" oninput="setHeroAdminDraft('subPeriods', this.value)" />
<button type="button" class="btn" onclick="withAction(() => heroAction('grant-subscription',{periods:Math.min(52,Math.max(1,parseInt(document.getElementById('hero-sub-periods').value,10)||1))}))" title="Выдать подписку на N периодов (длительность из runtime), без списания RUB">Выдать подписку</button>
<button type="button" class="btn warn" onclick="withAction(() => heroAction('revoke-subscription',{}))" title="Снять подписку сейчас; заряды баффов и ревайвы ужимаются до бесплатных лимитов">Снять подписку</button>
<button type="button" class="btn warn" onclick="withAction(() => heroAction('force-death',{}))" title="HP 0, state dead, ends combat; counts as a death if the hero was alive">Режим смерти</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('start-rest',{}, true))" title="Town rest (same duration as normal town rest)">Start rest (town)</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('start-roadside-rest',{}, true))" title="Roadside rest at current road position (not in excursion)">Start rest (roadside)</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Сервер снимает отдых: придорожный/лесной inline, отдых в городе или выход из town tour — в зависимости от состояния">Закончить отдых</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('start-adventure',{}, true))" title="Force mini-adventure (excursion) while walking on road">Start adventure</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('stop-adventure',{}, true))" title="Force return leg: walk back to road / rest start (excursion continues until arrival)">Stop adventure</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>
</div>
<p class="muted" style="margin-top:8px;margin-bottom:0">Roadside / adventure: герой жив, не в бою; adventure — <kbd>StateWalking</kbd> на дороге.</p>
<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">Город</label><select id="hero-teleport-town">${teleportOpts}</select></div>
<div><label class="muted">&nbsp;</label><br /><button type="button" class="btn" onclick="withAction(teleportHeroToTown)">Teleport</button></div>
</div>
<p class="muted" style="margin-top:6px;margin-bottom:0">Города из графа (<kbd>GET /admin/towns</kbd>). Герой жив и не в бою.</p>
${townTourApproachPanel}
</aside>
</div>
<div style="margin-top:14px;padding-top:12px;border-top:1px solid #2a3551">
<h4 style="margin:0 0 6px">Баффы / дебаффы (вручную)</h4>
<p class="muted" style="margin:0 0 8px">Эффект из серверного каталога, без списания бесплатных зарядов. Только вне боя (как и прочие правки героя).</p>
<div class="row" style="align-items:end">
<div>
<label class="muted">Бафф</label>
<select id="hero-admin-buff-type">
<option value="">—</option>
${ADMIN_BUFF_TYPES.map(t => `<option value="${e(t)}">${e(t)}</option>`).join("")}
</select>
</div>
<div>
<label class="muted">Дебафф</label>
<select id="hero-admin-debuff-type">
<option value="">—</option>
${ADMIN_DEBUFF_TYPES.map(t => `<option value="${e(t)}">${e(t)}</option>`).join("")}
</select>
</div>
<div></div>
</div>
<div style="margin-top:8px">
<button type="button" class="btn" onclick="withAction(applyHeroBuffAdmin)">Наложить бафф</button>
<span class="${state.rowStatus["hero-admin-buff"]?.ok ? "status-ok" : "status-err"}">${e(state.rowStatus["hero-admin-buff"]?.message || "")}</span>
<button type="button" class="btn" onclick="withAction(applyHeroDebuffAdmin)" style="margin-left:8px">Наложить дебафф</button>
<span class="${state.rowStatus["hero-admin-debuff"]?.ok ? "status-ok" : "status-err"}">${e(state.rowStatus["hero-admin-debuff"]?.message || "")}</span>
</div>
</div>
` : `<div class="muted">Выберите героя в блоке «Поиск героя» выше</div>`}
</div>
</div>
${heroExtra}`;
}
function sectionConstants() {
const src = runtimeDisplaySource();
const allRows = flattenObject(src);
const payEmpty = !state.runtime || !state.runtime.payload || Object.keys(state.runtime.payload).length === 0;
const hint = state.runtime
? (payEmpty && allRows.length
? "Показаны <kbd>effective</kbd> (слияние). В таблице <kbd>runtime_config</kbd> поле <kbd>payload</kbd> пустое — действуют базовые значения из кода (<code>tuning.DefaultValues</code>). «Save» записывает только переопределения в JSON."
: "Слияние: дефолты из кода + переопределения из <kbd>runtime_config.payload</kbd> (в БД хранится не полный набор, а дельта).")
: "Откройте вкладку или нажмите Load.";
return `
<div class="card">
<h3>Runtime constants</h3>
<p class="muted">${hint}</p>
<button class="btn" onclick="withAction(loadRuntime)">Load</button>
<button class="btn" onclick="withAction(saveRuntimeRows)">Save rows & reload</button>
<button class="btn" onclick="withAction(reloadRuntimeOnly)">Reload only</button>
</div>
<div class="card">
<h4>Константы по группам — всего ключей: ${allRows.length}</h4>
${runtimeConstantsGroupedHtml(allRows)}
</div>`;
}
function sectionBuffDebuff() {
const bd = state.buffDebuff;
if (!bd) {
return `<div class="card"><h3>Buffs &amp; Debuffs</h3><p class="muted">Нажмите Load или переключите вкладку снова.</p><button class="btn" onclick="withAction(loadBuffDebuff)">Load</button></div>`;
}
const eb = bd.effectiveBuffs || {};
const ed = bd.effectiveDebuffs || {};
const buffKeys = Object.keys(eb).sort();
const debuffKeys = Object.keys(ed).sort();
const buffRows = buffKeys.map(k => {
const b = eb[k] || {};
return `<tr>
<td><kbd title="тип баффа (id)">${e(k)}</kbd></td>
<td><input data-bd-kind="buff" data-bd-key="${e(k)}" data-bd-field="name" value="${e(b.name)}" title="Отображаемое имя" /></td>
<td><input data-bd-kind="buff" data-bd-key="${e(k)}" data-bd-field="durationMs" value="${e(b.durationMs)}" title="Длительность эффекта, мс" /></td>
<td><input data-bd-kind="buff" data-bd-key="${e(k)}" data-bd-field="magnitude" value="${e(b.magnitude)}" title="Сила эффекта (доля, например 0.5 = 50%)" /></td>
<td><input data-bd-kind="buff" data-bd-key="${e(k)}" data-bd-field="cooldownMs" value="${e(b.cooldownMs)}" title="Кулдаун после использования, мс" /></td>
</tr>`;
}).join("");
const debuffRows = debuffKeys.map(k => {
const d = ed[k] || {};
return `<tr>
<td><kbd title="тип дебаффа (id)">${e(k)}</kbd></td>
<td><input data-bd-kind="debuff" data-bd-key="${e(k)}" data-bd-field="name" value="${e(d.name)}" title="Отображаемое имя" /></td>
<td><input data-bd-kind="debuff" data-bd-key="${e(k)}" data-bd-field="durationMs" value="${e(d.durationMs)}" title="Длительность, мс" /></td>
<td><input data-bd-kind="debuff" data-bd-key="${e(k)}" data-bd-field="magnitude" value="${e(d.magnitude)}" title="Сила (доля урона/замедления в секунду и т.д.)" /></td>
</tr>`;
}).join("");
const payEmpty = !bd.payload || Object.keys(bd.payload).length === 0;
const hint = payEmpty
? "В таблице <kbd>buff_debuff_config</kbd> payload пустой — действуют встроенные значения из кода. «Save» записывает <strong>полные</strong> карты <kbd>buffs</kbd> и <kbd>debuffs</kbd> (все строки из таблицы)."
: "Переопределения из БД сливаются с сидами: для каждого ключа в JSON задаётся полное определение.";
return `
<div class="card">
<h3>Buffs &amp; Debuffs</h3>
<p class="muted">${hint}</p>
<button class="btn" onclick="withAction(loadBuffDebuff)">Load</button>
<button class="btn" onclick="withAction(saveBuffDebuffCatalog)">Save tables &amp; reload</button>
<button class="btn" onclick="withAction(reloadBuffDebuffOnly)">Reload only</button>
</div>
<div class="card">
<h4>Баффы (${buffKeys.length})</h4>
<p class="muted">Длительности в миллисекундах. Сохранение отправляет все строки целиком.</p>
<table class="table">
<thead><tr><th>Type</th><th>name</th><th>durationMs</th><th>magnitude</th><th>cooldownMs</th></tr></thead>
<tbody>${buffRows || `<tr><td colspan="5" class="muted">Нет данных</td></tr>`}</tbody>
</table>
</div>
<div class="card">
<h4>Дебаффы (${debuffKeys.length})</h4>
<table class="table">
<thead><tr><th>Type</th><th>name</th><th>durationMs</th><th>magnitude</th></tr></thead>
<tbody>${debuffRows || `<tr><td colspan="4" class="muted">Нет данных</td></tr>`}</tbody>
</table>
</div>`;
}
function sectionGear() {
const rowsDb = state.contentGearRows || [];
const rowsCat = state.gearCatalog || [];
const slots = Array.from(new Set([
...gearDistinctValues(rowsDb, "slot"),
...gearDistinctValues(rowsCat, "slot")
])).sort();
const subtypes = Array.from(new Set([
...gearDistinctValues(rowsDb, "subtype"),
...gearDistinctValues(rowsCat, "subtype")
])).sort();
const tierRar = ["common", "uncommon", "rare", "epic", "legendary"];
const extraRar = gearDistinctValues(rowsDb, "rarity").filter(r => !tierRar.includes(r));
const rarities = tierRar.concat(extraRar.sort());
const slotOpts = `<option value="">All slots</option>` + slots.map(s => `<option value="${e(s)}" ${state.gearFilterSlot === s ? "selected" : ""}>${e(s)}</option>`).join("");
const subOpts = `<option value="">All subtypes</option>` + subtypes.map(s => `<option value="${e(s)}" ${state.gearFilterSubtype === s ? "selected" : ""}>${e(s)}</option>`).join("");
const rarOpts = `<option value="">All rarities</option>` + rarities.map(s => `<option value="${e(s)}" ${state.gearFilterRarity === s ? "selected" : ""}>${e(s)}</option>`).join("");
const filteredDb = gearRowsFiltered(rowsDb, { slot: state.gearFilterSlot, rarity: state.gearFilterRarity, subtype: state.gearFilterSubtype, catalog: false });
const filteredCat = gearRowsFiltered(rowsCat, { slot: state.gearFilterSlot, rarity: "", subtype: state.gearFilterSubtype, catalog: true });
const catalogPage = paged(filteredCat, "gearCatalog", 10);
const catalogRows = catalogPage.items.map(c => `<tr><td>${e(c.slot)}</td><td>${e(c.subtype)}</td><td>${e(c.formId)}</td><td>${e(c.name)}</td><td>${e(c.statType)}</td></tr>`).join("");
const basePage = paged(filteredDb, "gearBase", 15);
const baseRows = basePage.items.map(g => `
<tr>
<td>${e(g.id)}</td><td>${e(g.slot)}</td><td>${e(g.subtype)}</td><td>${e(g.formId)}</td><td>${e(g.name)}</td><td>${e(g.rarity)}</td><td>${e(g.ilvl)}</td>
<td>${e(g.basePrimary)}/${e(g.primaryStat)}</td>
<td><button class="btn" onclick="openContentGearEditorById(${g.id})">Edit</button></td>
</tr>`).join("");
return `
${contentGearEditorHtml()}
<div class="card">
<h3>Gear in database</h3>
<p class="muted">Edit rows in table <kbd>gear</kbd> (names, stats, rarity, etc.). Merged in-code catalog below is read-only reference. Granting/equipping items for a hero is on the Heroes tab.</p>
<div class="row" style="align-items:end;margin-top:8px">
<div><label class="muted">Slot</label><select onchange="gearFilterChange('slot', this.value)">${slotOpts}</select></div>
<div><label class="muted">Subtype (weapon / armor class)</label><select onchange="gearFilterChange('subtype', this.value)">${subOpts}</select></div>
<div><label class="muted">Rarity (DB rows)</label><select onchange="gearFilterChange('rarity', this.value)">${rarOpts}</select></div>
<div><button type="button" class="btn" onclick="clearGearFilters()">Clear filters</button></div>
</div>
<button class="btn" onclick="withAction(loadContentGearBase)">Reload from DB</button>
<button class="btn" onclick="openNewContentGearEditor()">New row</button>
<button class="btn" onclick="withAction(loadGearCatalog)">Load reference catalog</button>
</div>
<div class="card">
<h4>DB rows (${filteredDb.length} matched)</h4>
<table class="table">
<thead><tr><th>ID</th><th>Slot</th><th>Subtype</th><th>FormId</th><th>Name</th><th>Rarity</th><th>iLvl</th><th>Base/Pri</th><th></th></tr></thead>
<tbody>${baseRows || `<tr><td colspan="9" class="muted">No rows match filters</td></tr>`}</tbody>
</table>
${pagerHtml("gearBase", basePage.page, basePage.total)}
</div>
<div class="card">
<h4>Reference: merged catalog (code) (${filteredCat.length} matched)</h4>
<p class="muted">Rarity filter applies only to DB table; catalog entries have no rarity until rolled.</p>
<table class="table"><thead><tr><th>Slot</th><th>Subtype</th><th>FormId</th><th>Name</th><th>StatType</th></tr></thead><tbody>${catalogRows || `<tr><td colspan="5" class="muted">Load reference catalog or relax filters</td></tr>`}</tbody></table>
${pagerHtml("gearCatalog", catalogPage.page, catalogPage.total)}
</div>`;
}
function sectionCombatSim() {
const sf = state.combatSimForm || {};
const sim = state.combatSimResult;
const ef = (sf.enemyFilter || "").trim().toLowerCase();
const enemiesForSim = (state.contentEnemies || []).filter(m => {
if (!ef) return true;
return String(m.type).toLowerCase().includes(ef) || String(m.name || "").toLowerCase().includes(ef);
});
const hasTypeInFilter = sf.enemyType && enemiesForSim.some(m => String(m.type) === String(sf.enemyType));
const enemyOrphanOpt = sf.enemyType && !hasTypeInFilter
? `<option value="${e(sf.enemyType)}" selected>${e(sf.enemyType)} (не в списке — ослабьте фильтр)</option>`
: "";
const enemySelectOpts = enemiesForSim.map(m => {
const sel = String(sf.enemyType) === String(m.type) ? " selected" : "";
return `<option value="${e(m.type)}"${sel}>${e(m.name)} · ${e(m.type)} · L${m.minLevel}${m.maxLevel}${m.isElite ? " ★" : ""}</option>`;
}).join("");
const hrows = state.combatSimHeroRows || [];
const heroPickRows = hrows.length
? hrows.map(x => `
<div class="list-row ${String(x.id) === String(sf.heroId) ? "active" : ""}" style="cursor:pointer" onclick="selectCombatSimHero(${x.id})">
<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)}</span>
</div>`).join("")
: `<div class="list-row"><span class="muted">Запросите список героев — поиск как на вкладке Heroes (имя или id)</span><span></span><span></span><span></span></div>`;
return `
<div class="card">
<h3>Симулятор боя (admin)</h3>
<p class="muted">То же ядро боя, что на сервере. Герой и архетип подтягиваются из БД. Шаблоны монстров — таблица <kbd>enemies</kbd> (вкладка «Monsters»). <strong>Запустить симуляцию</strong> — JSON и сводка. <strong>Показать вживую</strong> — арена; кадры ≥ <strong>10 ms</strong> (<kbd>delayMs</kbd>).</p>
<button type="button" class="btn" onclick="withAction(loadContentEnemies)">Обновить список архетипов из БД</button>
<h4 style="margin:14px 0 8px;font-size:14px">Герой</h4>
<div class="row-2">
<div>
<label>Поиск <span class="muted">(как Heroes: имя или числовой id / telegram)</span></label>
<input id="combat-sim-hero-query" value="${e(sf.heroQuery || "")}" oninput="state.combatSimForm.heroQuery=this.value" placeholder="Имя или id" />
</div>
<div style="align-self:end;display:flex;flex-wrap:wrap;gap:8px">
<button type="button" class="btn" onclick="withAction(searchHeroesForCombatSim)">Найти</button>
<button type="button" class="btn" onclick="withAction(loadRecentHeroesForCombatSim)">50 последних</button>
</div>
</div>
<div class="muted" style="margin:6px 0">Выбран: ${sf.heroId ? `<strong>ID ${e(sf.heroId)}</strong> ${e(sf.heroPickName || "")}` : "— кликните строку в списке"}</div>
<div class="list" style="max-height:200px">${heroPickRows}</div>
<h4 style="margin:16px 0 8px;font-size:14px">Архетип врага</h4>
<p class="muted" style="margin:0 0 8px">Список строится из загруженных шаблонов (кнопка выше или вкладка Monsters).</p>
<div class="row-2">
<div>
<label>Фильтр</label>
<input id="combat-sim-enemy-filter" value="${e(sf.enemyFilter || "")}" oninput="state.combatSimForm.enemyFilter=this.value" placeholder="wolf, demon…" />
</div>
<div style="align-self:end">
<button type="button" class="btn" onclick="applyCombatSimEnemyFilter()">Применить фильтр</button>
</div>
</div>
<div style="margin-top:10px">
<label>Архетип <span class="muted">(enemy type)</span></label>
<select id="combat-sim-enemy-select" style="margin-top:4px" onchange="onCombatSimEnemyTypeChange(this.value)">
<option value="" ${!sf.enemyType ? "selected" : ""}>— выберите —</option>
${enemyOrphanOpt}
${enemySelectOpts}
</select>
</div>
<h4 style="margin:16px 0 8px;font-size:14px">Параметры прогона</h4>
<details class="combat-sim-params-hint" style="margin:0 0 12px;padding:10px 12px;background:#151a28;border:1px solid #2a3551;border-radius:8px;font-size:13px;line-height:1.45">
<summary style="cursor:pointer;color:#cfe3ff;font-weight:600">Подсказка по параметрам боя</summary>
<ul style="margin:10px 0 0 18px;padding:0;color:#b8c5db">
<li><strong>enemyLevel</strong> — уровень экземпляра врага. Пусто или <kbd>0</kbd>: как во встречах в игре — случайный уровень в разбросе шаблона (<kbd>LevelVariance</kbd>, <kbd>MaxHeroLevelDiff</kbd> к герою). Число &gt; 0: фиксированный уровень экземпляра.</li>
<li><strong>delayMs</strong> — в <strong>Запустить симуляцию</strong>: реальная пауза на сервере между шагами боя (<code>wallClockDelayMs</code>), запрос дольше. В <strong>Показать вживую</strong>: бой считается сразу; это только скорость покадрового воспроизведения в окне (не меньше <strong>10 ms</strong> между событиями).</li>
<li><strong>maxEvents</strong> — сколько событий попадёт в ответ (удары, тики, смерть). Диапазон на сервере <strong>1…5000</strong>; при длинном бое хвост может обрезаться — увеличьте, чтобы увидеть конец боя в JSON или в живом логе.</li>
</ul>
</details>
<div class="row-2">
<div><label>enemyLevel <span class="muted">(optional, пусто = разброс как во встрече)</span></label><input type="number" value="${e(sf.enemyLevel || "")}" oninput="state.combatSimForm.enemyLevel=this.value" placeholder="auto" title="0 или пусто — уровень как при спавне врага (разброс шаблона); иначе фиксированный уровень" /></div>
<div><label>delayMs</label><input type="number" min="0" value="${e(sf.delayMs || 0)}" oninput="state.combatSimForm.delayMs=this.value" title="Симуляция: пауза на сервере между шагами. Вживую: интервал между кадрами воспроизведения (мин. 10 ms)" /></div>
</div>
<div><label>maxEvents</label><input type="number" value="${e(sf.maxEvents || 400)}" oninput="state.combatSimForm.maxEvents=this.value" title="Лимит событий в ответе (15000); при переполнении бой в логе обрезается" /></div>
<button type="button" class="btn" onclick="withAction(runAdminCombatSim)">Запустить симуляцию</button>
<button type="button" class="btn" onclick="withAction(runAdminCombatSimLive)">Показать вживую</button>
${sim ? `
<div style="margin-top:10px">
<div class="muted">Result: survived=${e(sim.survived)} elapsedMs=${e(sim.elapsedMs)} enemyLevel=${e(sim.enemyLevel)} heroHP=${e(sim.finalHeroHp)} enemyHP=${e(sim.finalEnemyHp)}</div>
<pre style="max-height:260px;overflow:auto">${e(JSON.stringify(sim.events || [], null, 2))}</pre>
</div>
` : ""}
</div>`;
}
function sectionMonsters() {
const page = paged(state.contentEnemies || [], "contentEnemies", 15);
const rows = page.items.map(m => `
<tr>
<td>${e(m.id)}</td>
<td><kbd>${e(m.type)}</kbd></td>
<td>${e(m.name)}</td>
<td>${m.isElite ? "★" : "—"}</td>
<td>${e(m.minLevel)}${e(m.maxLevel)}</td>
<td>${e(m.baseLevel)}</td>
<td>${e(m.maxHp)}</td>
<td>${e(m.attack)}/${e(m.defense)}</td>
<td>${e(m.speed)}</td>
<td>${e(m.hpPerLevel)}/${e(m.attackPerLevel)}/${e(m.defensePerLevel)}</td>
<td>${e(m.xpReward)}/${e(m.goldReward)}</td>
<td class="muted" style="max-width:180px;overflow:hidden;text-overflow:ellipsis" title="${e((m.specialAbilities || []).join(", "))}">${e((m.specialAbilities || []).join(", ") || "—")}</td>
<td><button type="button" class="btn" onclick='window.openContentEnemyEditorByType(${JSON.stringify(m.type)})'>Edit</button></td>
</tr>`).join("");
return `
<div class="card">
<h3>Монстры (шаблоны)</h3>
<p class="muted">Чтение/запись таблицы <kbd>enemies</kbd>. Изменения применяются к новым встречам; активный бой использует уже созданный экземпляр.</p>
<button type="button" class="btn" onclick="withAction(loadContentEnemies)">Обновить из БД</button>
<button type="button" class="btn" onclick="withAction(reloadEnemyTemplatesOnly)">Только reload в память (без записи)</button>
</div>
<div class="card">
<table class="table">
<thead><tr>
<th>ID</th><th>type</th><th>name</th><th>elite</th><th>band</th><th>baseLvl</th><th>HP</th><th>atk/def</th><th>spd</th><th>hp/atk/def +lvl</th><th>XP/gold</th><th>abilities</th><th></th>
</tr></thead>
<tbody>${rows || `<tr><td colspan="13" class="muted">Нажмите «Обновить из БД»</td></tr>`}</tbody>
</table>
${pagerHtml("contentEnemies", page.page, page.total)}
</div>
${contentEnemyEditorHtml()}`;
}
function sectionQuests() {
const page = paged(state.contentQuests, "contentQuests", 12);
const rows = page.items.map(q => `
<tr>
<td>${e(q.id)}</td><td>${e(q.npcId)}</td><td>${e(q.title)}</td><td>${e(q.type)}</td>
<td>${e(q.minLevel)}${e(q.maxLevel)}</td><td>${e(q.rewardXp)}/${e(q.rewardGold)}</td>
<td><button class="btn" onclick="openContentQuestEditorById(${q.id})">Edit</button></td>
</tr>`).join("");
return `
${contentQuestEditorHtml()}
<div class="card">
<h3>Quest templates in database</h3>
<p class="muted">Edit <kbd>quests</kbd> (titles, rewards, levels, targets). Browse towns/NPCs on the Towns tab for <kbd>npcId</kbd>. Giving a quest to a hero is on the Heroes tab.</p>
<button class="btn" onclick="withAction(loadContentQuests)">Reload from DB</button>
<button class="btn" onclick="openNewContentQuestEditor()">New template</button>
</div>
<div class="card">
<table class="table">
<thead><tr><th>ID</th><th>NPC</th><th>Title</th><th>Type</th><th>Level</th><th>XP/Gold</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="7" class="muted">Reload from DB</td></tr>`}</tbody>
</table>
${pagerHtml("contentQuests", page.page, page.total)}
</div>`;
}
function sectionTowns() {
const townsPage = paged(state.questTowns, "towns", 8);
const towns = townsPage.items.map(t => `<div class="list-row" onclick="withAction(() => selectTown(${t.id}))"><strong>${e(t.name)}</strong><span>Lvl ${e(t.levelMin)}-${e(t.levelMax)}</span><span></span><span>ID ${e(t.id)}</span></div>`).join("");
const npcPage = paged(state.townNpcs, "npcs", 8);
const npcs = npcPage.items.map(n => `<div class="list-row" onclick="withAction(() => selectNpc(${n.id}))"><strong>${e(n.name)}</strong><span>${e(n.type)}</span><span></span><span>ID ${e(n.id)}</span></div>`).join("");
const tmplPage = paged(state.npcQuests, "npcQuests", 10);
const templates = tmplPage.items.map(q => `
<tr><td>${e(q.id)}</td><td>${e(q.title)}</td><td>${e(q.type)}</td><td>${e(q.rewardXp)}/${e(q.rewardGold)}</td></tr>`).join("");
return `
<div class="card">
<h3>Towns &amp; NPCs (world)</h3>
<p class="muted">Browse towns, NPCs, and which templates an NPC offers. To accept a quest for a hero, use Heroes → Give quest from world.</p>
<button class="btn" onclick="withAction(loadQuestTowns)">Load towns</button>
</div>
<div class="row-2">
<div class="card"><h4>Towns</h4><div class="list">${towns || `<div class="list-row"><span class="muted">No towns loaded</span><span></span><span></span><span></span></div>`}</div>${pagerHtml("towns", townsPage.page, townsPage.total)}</div>
<div class="card"><h4>NPCs in town</h4><div class="list">${npcs || `<div class="list-row"><span class="muted">Select town</span><span></span><span></span><span></span></div>`}</div>${pagerHtml("npcs", npcPage.page, npcPage.total)}</div>
</div>
<div class="card"><h4>Quest templates by NPC</h4><table class="table"><thead><tr><th>ID</th><th>Title</th><th>Type</th><th>Rewards</th></tr></thead><tbody>${templates || `<tr><td colspan="4" class="muted">Select NPC</td></tr>`}</tbody></table>${pagerHtml("npcQuests", tmplPage.page, tmplPage.total)}</div>`;
}
function sectionPayments() {
const payPage = paged(state.payments, "payments", 12);
const rows = payPage.items.map(p => `
<tr onclick="withAction(() => openPayment(${p.id}))" style="cursor:pointer;">
<td>${e(p.id)}</td><td>${e(p.heroId)}</td><td>${e(p.type)}</td><td>${e(p.status)}</td><td>${e(p.amountRub)}</td><td>${e(p.createdAt)}</td>
</tr>`).join("");
const d = state.paymentDetail || {};
return `
<div class="card">
<h3>Payments</h3>
<div class="row">
<div><input id="payments-hero-id" placeholder="Filter by heroId" /></div>
<div><button class="btn" onclick="withAction(loadPayments)">Load list</button></div>
<div><input id="webhook-url" placeholder="https://.../api/v1/payments/telegram-webhook" /></div>
</div>
<button class="btn" onclick="withAction(setPaymentsWebhook)">Set webhook</button>
</div>
<div class="panel">
<div class="card">
<h4>Payment list</h4>
<table class="table"><thead><tr><th>ID</th><th>Hero</th><th>Type</th><th>Status</th><th>Amount</th><th>CreatedAt</th></tr></thead><tbody>${rows || `<tr><td colspan="6" class="muted">No payments loaded</td></tr>`}</tbody></table>
${pagerHtml("payments", payPage.page, payPage.total)}
</div>
<div class="card">
<h4>Payment details</h4>
<div class="kv"><kbd>ID</kbd><div>${e(d.id)}</div></div>
<div class="kv"><kbd>Hero ID</kbd><div>${e(d.heroId)}</div></div>
<div class="kv"><kbd>Type</kbd><div>${e(d.type)}</div></div>
<div class="kv"><kbd>Status</kbd><div>${e(d.status)}</div></div>
<div class="kv"><kbd>Buff Type</kbd><div>${e(d.buffType)}</div></div>
<div class="kv"><kbd>Amount RUB</kbd><div>${e(d.amountRub)}</div></div>
<div class="kv"><kbd>Created At</kbd><div>${e(d.createdAt)}</div></div>
<div class="kv"><kbd>Completed At</kbd><div>${e(d.completedAt)}</div></div>
</div>
</div>`;
}
function renderMain() {
if (state.tab === "server") return sectionServer();
if (state.tab === "heroes") return sectionHeroes();
if (state.tab === "constants") return sectionConstants();
if (state.tab === "buffDebuff") return sectionBuffDebuff();
if (state.tab === "gear") return sectionGear();
if (state.tab === "monsters") return sectionMonsters();
if (state.tab === "combatSim") return sectionCombatSim();
if (state.tab === "quests") return sectionQuests();
if (state.tab === "towns") return sectionTowns();
if (state.tab === "payments") return sectionPayments();
return "";
}
function render() {
document.getElementById("app").innerHTML = `
<div class="layout">
<aside class="nav">
<div class="brand">AutoHero Admin</div>
<div class="card">
<div class="muted">Auth (same as API BasicAuth)</div>
<input id="login-user" placeholder="Username" value="${e(state.auth.username)}" />
<input id="login-pass" type="password" placeholder="Password" value="${e(state.auth.password)}" />
<button class="btn" onclick="login()">Save credentials</button>
</div>
<button class="${state.tab==='server'?'active':''}" onclick="setTab('server')">1. Server</button>
<button class="${state.tab==='heroes'?'active':''}" onclick="setTab('heroes')">2. Heroes</button>
<button class="${state.tab==='constants'?'active':''}" onclick="setTab('constants')">3. Constants</button>
<button class="${state.tab==='buffDebuff'?'active':''}" onclick="setTab('buffDebuff')">4. Buffs / Debuffs</button>
<button class="${state.tab==='gear'?'active':''}" onclick="setTab('gear')">5. Gear (content)</button>
<button class="${state.tab==='monsters'?'active':''}" onclick="setTab('monsters')">6. Monsters</button>
<button class="${state.tab==='combatSim'?'active':''}" onclick="setTab('combatSim')">7. Combat sim</button>
<button class="${state.tab==='quests'?'active':''}" onclick="setTab('quests')">8. Quests (content)</button>
<button class="${state.tab==='towns'?'active':''}" onclick="setTab('towns')">9. Towns</button>
<button class="${state.tab==='payments'?'active':''}" onclick="setTab('payments')">10. Payments</button>
</aside>
<main class="main">
${state.message ? `<div class="card">${e(state.message)}</div>` : ""}
${renderMain()}
</main>
</div>
${state.confirm.open ? `<div class="modal-backdrop"><div class="modal"><h3>${e(state.confirm.title)}</h3><p>${e(state.confirm.message)}</p><button class="btn warn" onclick="confirmProceed()">Confirm</button><button class="btn" onclick="closeConfirm()">Cancel</button></div></div>` : ""}
${combatSimLiveModalHtml()}`;
}
window.withAction = withAction;
window.setTab = setTab;
window.login = login;
window.setPage = setPage;
window.openConfirm = openConfirm;
window.closeConfirm = closeConfirm;
window.confirmProceed = confirmProceed;
function setHeroAdminDraft(field, value) {
state.heroAdminDraft[field] = value;
}
window.setHeroAdminDraft = setHeroAdminDraft;
window.connectHeroLiveWS = connectHeroLiveWS;
window.stopHeroLiveWS = stopHeroLiveWS;
window.toggleLiveSnapshotOpen = toggleLiveSnapshotOpen;
window.toggleHeroQuestWorldOpen = toggleHeroQuestWorldOpen;
window.toggleHeroSearchOpen = toggleHeroSearchOpen;
window.jsonViewerToggle = jsonViewerToggle;
window.loadContentEnemies = loadContentEnemies;
window.openContentEnemyEditorByType = openContentEnemyEditorByType;
window.closeContentEnemyEditor = closeContentEnemyEditor;
window.saveContentEnemy = saveContentEnemy;
window.reloadEnemyTemplatesOnly = reloadEnemyTemplatesOnly;
window.patchMonsterField = patchMonsterField;
window.stopCombatSimLive = stopCombatSimLive;
window.searchHeroesForCombatSim = searchHeroesForCombatSim;
window.loadRecentHeroesForCombatSim = loadRecentHeroesForCombatSim;
window.selectCombatSimHero = selectCombatSimHero;
window.applyCombatSimEnemyFilter = applyCombatSimEnemyFilter;
window.onCombatSimEnemyTypeChange = onCombatSimEnemyTypeChange;
render();
</script>
</body>
</html>