@ -85,9 +85,9 @@
contentQuests: [],
contentGearRows: [],
contentGearEditor: null,
contentQuestEditor: null,
contentEnemies: [],
contentEnemyEditor: null,
contentQuestEditor: null,
gearFilterSlot: "",
gearFilterRarity: "",
gearFilterSubtype: "",
@ -117,11 +117,8 @@
/** 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 — чекбоксы в редакторе врагов */
const ADMIN_ENEMY_SPECIAL_ABILITIES = [
"burn", "slow", "critical", "poison", "freeze", "ice_slow", "stun", "dodge",
"regen", "burst", "chain_lightning", "summon"
];
/** 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('"', "" "); }
function authHeader() { return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`; }
@ -968,93 +965,53 @@
render();
}
async function loadContentGearBase() { const data = await api("content/gear-base"); state.contentGearRows = data.gear || []; render(); }
async function loadContentQuests() { const data = await api("content/quests"); state.contentQuests = data.quests || []; render(); }
async function loadContentEnemies() {
const data = await api("content/enemies");
state.contentEnemies = data.enemies || [];
render();
}
function openContentEnemyEditorByType(enemyType) {
const row = (state.contentEnemies || []).find(x => x.type === enemyType);
async function reloadEnemyTemplatesOnly() {
await api("content/enemies/reload", { method: "POST", body: "{}" });
setMessage("Enemy templates reloaded from DB into server memory");
}
function openContentEnemyEditorByType(type) {
const row = (state.contentEnemies || []).find(x => x.type === type);
if (!row) return;
state.contentEnemyEditor = Object.assign({}, row, {
_abilities: new Set(row.specialAbilities || [])
_abilitiesText: Array.isArray(row.specialAbilities) ? row.specialAbilities.join(", ") : ""
});
render();
}
function closeContentEnemyEditor() { state.contentEnemyEditor = null; render(); }
async function saveContentEnemy() {
const ed = state.contentEnemyEditor;
if (!ed) return;
const type = String(ed.type || "").trim();
const maxHp = Number(document.getElementById("cem-maxHp").value || 0);
const typ = document.getElementById("me-type")?.value?.trim();
if (!typ) return;
const maxHp = Number(document.getElementById("me-maxHp").value || 0);
const abText = document.getElementById("me-abilities").value || "";
const specialAbilities = abText.split(",").map(s => s.trim()).filter(Boolean);
const body = {
type ,
name: document.getElementById("cem-name").value.trim() ,
maxHp ,
id: Number(document.getElementById("me-id").value || 0) ,
type: typ ,
name: document.getElementById("me-name").value ,
hp: maxHp,
attack: Number(document.getElementById("cem-attack").value || 0),
defense: Number(document.getElementById("cem-defense").value || 0),
speed: Number(document.getElementById("cem-speed").value || 0),
critChance: Number(document.getElementById("cem-critChance").value || 0),
minLevel: Number(document.getElementById("cem-minLevel").value || 1),
maxLevel: Number(document.getElementById("cem-maxLevel").value || 1),
xpReward: Number(document.getElementById("cem-xpReward").value || 0),
goldReward: Number(document.getElementById("cem-goldReward").value || 0),
isElite: !!document.getElementById("cem-isElite").checked,
specialAbilities: ADMIN_ENEMY_SPECIAL_ABILITIES.filter(a => document.getElementById("cem-ab-" + a).checked)
maxHp,
attack: Number(document.getElementById("me-attack").value || 0),
defense: Number(document.getElementById("me-defense").value || 0),
speed: Number(document.getElementById("me-speed").value || 0),
critChance: Number(document.getElementById("me-critChance").value || 0),
minLevel: Number(document.getElementById("me-minLevel").value || 1),
maxLevel: Number(document.getElementById("me-maxLevel").value || 100),
xpReward: Number(document.getElementById("me-xpReward").value || 0),
goldReward: Number(document.getElementById("me-goldReward").value || 0),
specialAbilities,
isElite: !!document.getElementById("me-isElite")?.checked
};
await api("content/enemies/" + encodeURIComponent(type) , { method: "PUT", body: JSON.stringify(body) });
await api(`content/enemies/${encodeURIComponent(typ)}` , { method: "PUT", body: JSON.stringify(body) });
state.contentEnemyEditor = null;
await loadContentEnemies();
setMessage("Enemy template saved; in-memory templates reloaded");
}
async function reloadEnemyTemplatesOnly() {
await api("content/enemies/reload", { method: "POST", body: "{}" });
setMessage("Enemy templates reloaded from DB");
}
function contentEnemyEditorHtml() {
const ed = state.contentEnemyEditor;
if (!ed) return "";
const abs = ed._abilities instanceof Set ? ed._abilities : new Set(ed.specialAbilities || []);
const abChecks = ADMIN_ENEMY_SPECIAL_ABILITIES.map(a =>
`< label style = "display:inline-block;margin:4px 10px 4px 0" > < input type = "checkbox" id = "cem-ab-${a}" $ { abs . has ( a ) ? " checked " : " " } / > ${e(a)}< / label > `
).join("");
return `
< div class = "card" >
< h4 > Edit enemy: < kbd > ${e(ed.type)}< / kbd > < / h4 >
< p class = "muted" > Запись в таблице < kbd > enemies< / kbd > . После сохранения сервер подставляет < kbd > hp< / kbd > = < kbd > maxHp< / kbd > и перезагружает шаблоны в памяти.< / p >
< input type = "hidden" id = "cem-type" value = "${e(ed.type)}" / >
< div class = "row-2" >
< div > < label > type (id строки)< / label > < input id = "cem-type-ro" value = "${e(ed.type)}" disabled / > < / div >
< div > < label > name< / label > < input id = "cem-name" value = "${e(ed.name)}" / > < / div >
< / div >
< div class = "row-2" >
< div > < label > maxHp< / label > < input id = "cem-maxHp" type = "number" value = "${e(ed.maxHp)}" title = "Базовые HP шаблона" / > < / div >
< div > < label > isElite< / label > < label style = "display:block;margin-top:8px" > < input type = "checkbox" id = "cem-isElite" $ { ed . isElite ? " checked " : " " } / > elite< / label > < / div >
< / div >
< div class = "row-2" >
< div > < label > attack< / label > < input id = "cem-attack" type = "number" value = "${e(ed.attack)}" / > < / div >
< div > < label > defense< / label > < input id = "cem-defense" type = "number" value = "${e(ed.defense)}" / > < / div >
< / div >
< div class = "row-2" >
< div > < label > speed (атак/сек)< / label > < input id = "cem-speed" type = "number" step = "any" value = "${e(ed.speed)}" / > < / div >
< div > < label > critChance (0– 1)< / label > < input id = "cem-critChance" type = "number" step = "any" value = "${e(ed.critChance)}" / > < / div >
< / div >
< div class = "row-2" >
< div > < label > minLevel< / label > < input id = "cem-minLevel" type = "number" value = "${e(ed.minLevel)}" / > < / div >
< div > < label > maxLevel< / label > < input id = "cem-maxLevel" type = "number" value = "${e(ed.maxLevel)}" / > < / div >
< / div >
< div class = "row-2" >
< div > < label > xpReward< / label > < input id = "cem-xpReward" type = "number" value = "${e(ed.xpReward)}" / > < / div >
< div > < label > goldReward< / label > < input id = "cem-goldReward" type = "number" value = "${e(ed.goldReward)}" / > < / div >
< / div >
< div > < span class = "muted" > Special abilities< / span > < / div >
< div style = "margin:6px 0 10px" > ${abChecks}< / div >
< button class = "btn" onclick = "withAction(saveContentEnemy)" > Save< / button >
< button class = "btn" onclick = "closeContentEnemyEditor()" > Cancel< / button >
< / div > `;
setMessage("Monster template saved; in-memory templates updated");
}
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,
@ -1216,6 +1173,46 @@
< 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" >
< h4 > Edit monster template< / h4 >
< p class = "muted" > Таблица < kbd > enemies< / kbd > . Поле < kbd > type< / kbd > не меняется (ключ). После сохранения шаблоны подхватываются в память сервера.< / 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)}" / > < / div >
< div > < label > name< / label > < input id = "me-name" value = "${e(ed.name)}" / > < / div >
< / div >
< div class = "row-2" >
< div > < label > maxHp< / label > < input id = "me-maxHp" type = "number" value = "${e(ed.maxHp)}" / > < / 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 " : " " } / > elite< / label > < / div >
< / div >
< div class = "row-2" >
< div > < label > attack< / label > < input id = "me-attack" type = "number" value = "${e(ed.attack)}" / > < / div >
< div > < label > defense< / label > < input id = "me-defense" type = "number" value = "${e(ed.defense)}" / > < / div >
< / div >
< div class = "row-2" >
< div > < label > speed< / label > < input id = "me-speed" type = "number" step = "any" value = "${e(ed.speed)}" / > < / div >
< div > < label > critChance (0– 1)< / label > < input id = "me-critChance" type = "number" step = "any" value = "${e(ed.critChance)}" / > < / div >
< / div >
< div class = "row-2" >
< div > < label > minLevel< / label > < input id = "me-minLevel" type = "number" value = "${e(ed.minLevel)}" / > < / div >
< div > < label > maxLevel< / label > < input id = "me-maxLevel" type = "number" value = "${e(ed.maxLevel)}" / > < / div >
< / div >
< div class = "row-2" >
< div > < label > xpReward< / label > < input id = "me-xpReward" type = "number" value = "${e(ed.xpReward)}" / > < / div >
< div > < label > goldReward< / label > < input id = "me-goldReward" type = "number" value = "${e(ed.goldReward)}" / > < / div >
< / div >
< div > < label > specialAbilities (через запятую)< / label > < input id = "me-abilities" value = "${e(abVal)}" placeholder = "burn, regen" / > < / div >
< p class = "muted" style = "margin-top:4px" > Допустимые теги: ${e(abHint)}< / p >
< button class = "btn" onclick = "withAction(saveContentEnemy)" > Сохранить< / button >
< 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();
@ -1321,7 +1318,7 @@
render();
if (tab === "constants" & & !state.runtime) withAction(loadRuntime);
if (tab === "buffDebuff" & & !state.buffDebuff) withAction(loadBuffDebuff);
if (tab === "enemies" ) withAction(loadContentEnemies);
if (tab === "monsters" & & (!state.contentEnemies || state.contentEnemies.length === 0) ) withAction(loadContentEnemies);
}
function sectionServer() {
@ -1715,6 +1712,41 @@
< / 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.maxHp)}< / td >
< td > ${e(m.attack)}/${e(m.defense)}< / td >
< td > ${e(m.speed)}< / 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 class = "btn" onclick = "openContentEnemyEditorByType(${JSON.stringify(m.type)})" > Edit< / button > < / td >
< / tr > `).join("");
return `
${contentEnemyEditorHtml()}
< div class = "card" >
< h3 > Монстры (шаблоны)< / h3 >
< p class = "muted" > Чтение/запись таблицы < kbd > enemies< / kbd > . Изменения применяются к новым встречам; активный бой использует уже созданный экземпляр.< / p >
< button class = "btn" onclick = "withAction(loadContentEnemies)" > Обновить из БД< / button >
< 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 > lvl< / th > < th > HP< / th > < th > atk/def< / th > < th > spd< / th > < th > XP/gold< / th > < th > abilities< / th > < th > < / th >
< / tr > < / thead >
< tbody > ${rows || `< tr > < td colspan = "11" class = "muted" > Нажмите «Обновить из БД»< / td > < / tr > `}< / tbody >
< / table >
${pagerHtml("contentEnemies", page.page, page.total)}
< / div > `;
}
function sectionQuests() {
const page = paged(state.contentQuests, "contentQuests", 12);
const rows = page.items.map(q => `
@ -1740,45 +1772,6 @@
< / div > `;
}
function sectionEnemies() {
const page = paged(state.contentEnemies, "contentEnemies", 15);
const rows = page.items.map(en => {
const abs = (en.specialAbilities || []).join(", ");
const elite = en.isElite ? "elite" : "base";
return `< tr >
< td > < kbd title = "id в БД" > ${e(en.id)}< / kbd > < / td >
< td > < kbd > ${e(en.type)}< / kbd > < / td >
< td > ${e(en.name)}< / td >
< td > ${e(elite)}< / td >
< td > ${e(en.minLevel)}– ${e(en.maxLevel)}< / td >
< td > ${e(en.maxHp)}< / td >
< td > ${e(en.attack)}/${e(en.defense)}< / td >
< td > ${e(en.speed)}< / td >
< td > ${e(en.critChance)}< / td >
< td > ${e(en.xpReward)}/${e(en.goldReward)}< / td >
< td class = "muted" style = "max-width:180px;overflow:hidden;text-overflow:ellipsis" title = "${e(abs)}" > ${e(abs || "—")}< / td >
< td > < button class = "btn" onclick = "openContentEnemyEditorByType(${JSON.stringify(en.type)})" > Edit< / button > < / td >
< / tr > `;
}).join("");
return `
${contentEnemyEditorHtml()}
< div class = "card" >
< h3 > Монстры (шаблоны врагов)< / h3 >
< p class = "muted" > Таблица < kbd > enemies< / kbd > : базовые статы, уровни, награды, способности. Изменения после «Save» сразу попадают в память процесса. Новые типы добавляются миграциями/сидом, не из этой формы.< / p >
< button class = "btn" onclick = "withAction(loadContentEnemies)" > Обновить из БД< / button >
< 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 > Class< / th > < th > Levels< / th > < th > maxHP< / th > < th > Atk/Def< / th > < th > Spd< / th > < th > Crit< / th > < th > XP/Au< / th > < th > Abilities< / th > < th > < / th >
< / tr > < / thead >
< tbody > ${rows || `< tr > < td colspan = "12" class = "muted" > Нет данных — нажмите «Обновить из БД»< / td > < / tr > `}< / tbody >
< / table >
${pagerHtml("contentEnemies", 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("");
@ -1843,7 +1836,7 @@
if (state.tab === "constants") return sectionConstants();
if (state.tab === "buffDebuff") return sectionBuffDebuff();
if (state.tab === "gear") return sectionGear();
if (state.tab === "enemies") return sectionEnemie s();
if (state.tab === "monsters") return sectionMonster s();
if (state.tab === "quests") return sectionQuests();
if (state.tab === "towns") return sectionTowns();
if (state.tab === "payments") return sectionPayments();
@ -1865,9 +1858,10 @@
< button class = "${state.tab==='constants'?'active':''}" onclick = "setTab('constants')" > 3. Constants< / button >
< button class = "${state.tab==='buffDebuff'?'active':''}" onclick = "setTab('buffDebuff')" > 4. Buffs / Debuffs< / button >
< button class = "${state.tab==='gear'?'active':''}" onclick = "setTab('gear')" > 5. Gear (content)< / button >
< button class = "${state.tab==='quests'?'active':''}" onclick = "setTab('quests')" > 6. Quests (content)< / button >
< button class = "${state.tab==='towns'?'active':''}" onclick = "setTab('towns')" > 7. Towns< / button >
< button class = "${state.tab==='payments'?'active':''}" onclick = "setTab('payments')" > 8. Payments< / button >
< button class = "${state.tab==='monsters'?'active':''}" onclick = "setTab('monsters')" > 6. Monsters< / button >
< button class = "${state.tab==='quests'?'active':''}" onclick = "setTab('quests')" > 7. Quests (content)< / button >
< button class = "${state.tab==='towns'?'active':''}" onclick = "setTab('towns')" > 8. Towns< / button >
< button class = "${state.tab==='payments'?'active':''}" onclick = "setTab('payments')" > 9. Payments< / button >
< / aside >
< main class = "main" >
${state.message ? `< div class = "card" > ${e(state.message)}< / div > ` : ""}
@ -1889,6 +1883,11 @@
window.toggleLiveSnapshotOpen = toggleLiveSnapshotOpen;
window.toggleHeroQuestWorldOpen = toggleHeroQuestWorldOpen;
window.jsonViewerToggle = jsonViewerToggle;
window.loadContentEnemies = loadContentEnemies;
window.openContentEnemyEditorByType = openContentEnemyEditorByType;
window.closeContentEnemyEditor = closeContentEnemyEditor;
window.saveContentEnemy = saveContentEnemy;
window.reloadEnemyTemplatesOnly = reloadEnemyTemplatesOnly;
render();
< / script >
< / body >