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.

1748 lines
106 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; }
</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,
contentQuestEditor: null,
gearFilterSlot: "",
gearFilterRarity: "",
gearFilterSubtype: "",
grantGearSearchQuery: "",
heroGrantGearCandidates: [],
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,
_heroLiveSnapshot: null,
_jsonViewerOpenPaths: 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"];
function e(v) { return String(v ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); }
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: "Множитель к интервалу атак в бою; чем выше — тем реже удары (длиннее паузы).",
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",
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;
render();
}
function toggleHeroQuestWorldOpen(ev) {
if (ev) ev.preventDefault();
state._heroQuestWorldOpen = !state._heroQuestWorldOpen;
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();
}
function stopHeroMovementPoll() {
if (state._heroPollTimer) clearInterval(state._heroPollTimer);
state._heroPollTimer = null;
state._heroPollUntil = null;
}
function stopHeroLiveWS() {
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;
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(); render(); return; }
if (Date.now() > state._heroPollUntil) { stopHeroMovementPoll(); render(); return; }
try {
const hero = await api(`heroes/${state.selectedHeroId}`);
state.selectedHero = hero;
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();
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;
state._jsonViewerOpenPaths = Object.create(null);
render();
ws.onopen = () => {
state._heroLiveWsStatus = "connected";
render();
};
ws.onclose = () => {
state._heroLiveWsStatus = "disconnected";
render();
};
ws.onerror = () => {
state._heroLiveWsStatus = "error";
state._heroLiveWsError = "WebSocket error";
render();
};
ws.onmessage = (evt) => {
try {
const data = JSON.parse(evt.data);
if (data && data.error) {
state._heroLiveWsStatus = "error";
state._heroLiveWsError = String(data.error);
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();
render();
}
} catch (err) {
state._heroLiveWsStatus = "error";
state._heroLiveWsError = "Failed to parse WS payload";
render();
}
};
}
function jsonChildPath(parent, segment) {
if (parent === "$") return `$.${segment}`;
return `${parent}.${segment}`;
}
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;
render();
}
function jsonTreeHtml(value, path) {
const pathArg = JSON.stringify(path);
const open = state._jsonViewerOpenPaths && state._jsonViewerOpenPaths[path];
const openAttr = open ? " open" : "";
if (value === null) return `<span class="jv-null">null</span>`;
if (value === undefined) return `<span class="jv-null">undefined</span>`;
const t = typeof value;
if (t === "boolean" || t === "number") return `<span class="jv-lit">${e(String(value))}</span>`;
if (t === "string") return `<span class="jv-str">"${e(value)}"</span>`;
if (Array.isArray(value)) {
if (value.length === 0) return `<span class="jv-empty">[]</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" 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">{}</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" onclick="jsonViewerToggle(${pathArg}, event)">{${keys.length} полей}</summary><div class="jv-ch">${inner}</div></details>`;
}
return `<span>${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 (live && live.online) {
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>`);
}
}
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" 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>${e(status)}${heroId ? " (hero " + e(heroId) + ")" : ""}</div></div>
<div class="kv"><kbd>lastUpdate</kbd><div>${e(last)}</div></div>
${err ? `<div class="status-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"${openAttr}>
<summary onclick="toggleLiveSnapshotOpen(event)">Снимок: hero + heroMove (дерево)</summary>
${heroSnapshotTreeHtml()}
</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();
}
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;
render();
}
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 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>`;
}
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);
}
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>`;
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 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>`;
}
return `
<div class="panel">
<div class="card">
<h3>Hero search</h3>
<div class="row">
<div><input id="hero-query" placeholder="Name (list) or numeric id (one hero)" /></div>
<div><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>
<div class="card">
<h3>Hero details</h3>
${state.selectedHeroId ? `
<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>${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>${e(h.level)}</div></div>
<div class="kv"><kbd>HP</kbd><div>${e(h.hp)}/${e(h.maxHp)}</div></div>
<div class="kv"><kbd>Gold</kbd><div>${e(h.gold)}</div></div>
<div class="row">
<div><input id="hero-hp" type="number" placeholder="New HP" /></div>
<div><input id="hero-gold" type="number" placeholder="New Gold" /></div>
<div><input id="hero-level" type="number" placeholder="New Level" /></div>
</div>
<button class="btn" onclick="withAction(() => heroAction('set-hp',{hp:Number(document.getElementById('hero-hp').value)}))">Set HP</button>
<button class="btn" onclick="withAction(() => heroAction('set-gold',{gold:Number(document.getElementById('hero-gold').value)}))">Set Gold</button>
<button class="btn" onclick="withAction(() => heroAction('set-level',{level:Number(document.getElementById('hero-level').value)}))">Set Level</button>
<button class="btn" onclick="withAction(() => heroAction('revive',{}))">Revive</button>
<button class="btn" onclick="withAction(() => heroAction('start-rest',{}, true))" title="Town rest (same duration as normal town rest)">Start rest (town)</button>
<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 class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Exit roadside or adventure-inline rest back to walking">Stop rest</button>
<button class="btn" onclick="withAction(() => heroAction('start-adventure',{}, true))" title="Force mini-adventure (excursion) while walking on road">Start adventure</button>
<button class="btn" onclick="withAction(() => heroAction('stop-adventure',{}, true))" title="End active excursion session">Stop adventure</button>
<button class="btn" onclick="withAction(() => heroAction('leave-town',{}))">Leave Town</button>
<p class="muted" style="margin-top:6px">Roadside / adventure controls require the hero alive, not in combat; adventure start needs <kbd>StateWalking</kbd> on a road.</p>
<div class="row" style="margin-top:12px;align-items:end">
<div><label class="muted">Teleport</label><button type="button" class="btn" onclick="withAction(loadTeleportTowns)">Load towns</button></div>
<div><select id="hero-teleport-town">${teleportOpts}</select></div>
<div><button type="button" class="btn" onclick="withAction(teleportHeroToTown)">Teleport to town</button></div>
</div>
<p class="muted" style="margin-top:6px">Towns come from the loaded road graph (<kbd>GET /admin/towns</kbd>). Hero must be alive and not in combat.</p>
<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">Select hero from list</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 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 === "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==='quests'?'active':''}" onclick="setTab('quests')">6. Quests (content)</button>
<button class="${state.tab==='towns'?'active':''}" onclick="setTab('towns')">7. Towns</button>
<button class="${state.tab==='payments'?'active':''}" onclick="setTab('payments')">8. 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>` : ""}`;
}
window.withAction = withAction;
window.setTab = setTab;
window.login = login;
window.setPage = setPage;
window.openConfirm = openConfirm;
window.closeConfirm = closeConfirm;
window.confirmProceed = confirmProceed;
window.connectHeroLiveWS = connectHeroLiveWS;
window.stopHeroLiveWS = stopHeroLiveWS;
window.toggleLiveSnapshotOpen = toggleLiveSnapshotOpen;
window.toggleHeroQuestWorldOpen = toggleHeroQuestWorldOpen;
window.jsonViewerToggle = jsonViewerToggle;
render();
</script>
</body>
</html>