|
|
<!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; }
|
|
|
</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,
|
|
|
};
|
|
|
state._confirmAction = null;
|
|
|
|
|
|
function e(v) { return String(v ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); }
|
|
|
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: "Глобальный масштаб исходящего урона героя в бою.",
|
|
|
enemyDodgeChance: "Базовая вероятность уклонения врага.",
|
|
|
enemyCriticalMinChance: "Нижняя планка шанса крита врага.",
|
|
|
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",
|
|
|
minAttackIntervalMs: "hero_combat",
|
|
|
combatPaceMultiplier: "hero_combat",
|
|
|
agilityCoef: "hero_combat",
|
|
|
maxAttackSpeed: "hero_combat",
|
|
|
minAttackSpeed: "hero_combat",
|
|
|
enemyDodgeChance: "enemy_combat",
|
|
|
enemyCriticalMinChance: "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();
|
|
|
}
|
|
|
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 startHeroMovementPoll(durationSec = 55) {
|
|
|
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 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>`;
|
|
|
}
|
|
|
async function loadHero(heroId) {
|
|
|
if (!heroId) return;
|
|
|
if (state.selectedHeroId != null && state.selectedHeroId !== heroId) stopHeroMovementPoll();
|
|
|
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); }
|
|
|
|
|
|
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>
|
|
|
<div class="card">
|
|
|
<h4>Give quest from world — this hero</h4>
|
|
|
<button class="btn" onclick="withAction(loadQuestTowns)">Load towns</button>
|
|
|
<button class="btn" onclick="withAction(() => loadHero(state.selectedHeroId))">Reload hero quests</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><th>Action</th></tr></thead><tbody>${templates || `<tr><td colspan="5" class="muted">Select NPC</td></tr>`}</tbody></table>${pagerHtml("npcQuests", tmplPage.page, tmplPage.total)}</div>`;
|
|
|
}
|
|
|
|
|
|
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)}
|
|
|
<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))">Start Rest</button>
|
|
|
<button class="btn" onclick="withAction(() => heroAction('leave-town',{}))">Leave Town</button>
|
|
|
<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 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 & 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 & Debuffs</h3>
|
|
|
<p class="muted">${hint}</p>
|
|
|
<button class="btn" onclick="withAction(loadBuffDebuff)">Load</button>
|
|
|
<button class="btn" onclick="withAction(saveBuffDebuffCatalog)">Save tables & 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 & 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;
|
|
|
render();
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|