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