TH1/Tools/Dashboard/js/gamebalance.js

1880 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
TH1 Dashboard - Game Balance Analysis (gamebalance.js)
============================================================ */
// ── Enum data (from C# source) ──
const GB_UNIT_TYPES = {
0: { en: 'None', cn: '-' },
1: { en: 'Warrior', cn: '步兵' },
2: { en: 'Rider', cn: '骑兵' },
3: { en: 'Archer', cn: '弓箭手' },
4: { en: 'Defender', cn: '盾兵' },
5: { en: 'Knights', cn: '重骑兵' },
6: { en: 'Catapult', cn: '炮手' },
7: { en: 'Swordsman', cn: '剑士' },
8: { en: 'Cloak', cn: '间谍' },
9: { en: 'Minder', cn: '脑控' },
10: { en: 'Boat', cn: '小船' },
11: { en: 'Ship', cn: '远战船' },
12: { en: 'RammerShip', cn: '近战船' },
13: { en: 'BomberShip', cn: '战舰' },
14: { en: 'Giant', cn: '英雄' },
15: { en: 'BigGuy', cn: '巨汉' },
16: { en: 'Phantom', cn: '幻影' },
17: { en: 'Dabber', cn: 'Dabber' },
18: { en: 'Juggernaut', cn: '巨人战船' },
19: { en: 'GiantJuggernaut', cn: '英雄战船' },
20: { en: 'KaguyaFrenchAnimalWarrior', cn: '竹林兽兵' },
21: { en: 'KaguyaFrenchWarrior', cn: '竹林步兵' },
22: { en: 'KaguyaFrenchCatapult', cn: '竹林炮手' },
23: { en: 'KaguyaFrenchMokouEgg', cn: '妹红蛋' },
24: { en: 'KaguyaFrenchReisenIllusion', cn: '月兔幻象' },
25: { en: 'KaguyaFrenchWolf', cn: '竹林狼' },
26: { en: 'RemiliaEgyptianKoakuma', cn: '小恶魔' },
27: { en: 'RemiliaEgyptianKoakumaLion', cn: '小恶魔狮' },
28: { en: 'MoriyaRider', cn: '守矢骑兵' },
29: { en: 'MoriyaKnight', cn: '守矢重骑' },
30: { en: 'MoriyaHebi', cn: '守矢蛇' },
31: { en: 'WolfJuggernaut', cn: '狼战船' },
32: { en: 'BonePile', cn: '骨堆' },
33: { en: 'KomeijiIndianBigGuy', cn: '古明地巨汉' },
34: { en: 'KomeijiIndianRider', cn: '古明地骑兵' },
35: { en: 'KomeijiIndianKnight', cn: '古明地重骑' },
36: { en: 'KomeijiIndianArcher', cn: '古明地弓手' },
37: { en: 'KomeijiIndianCatapult', cn: '古明地炮手' },
38: { en: 'KomeijiIndianShip', cn: '古明地战船' },
39: { en: 'KomeijiIndianBomberShip', cn: '古明地远船' },
40: { en: 'KomeijiIndianJuggernaut', cn: '古明地巨人船' },
};
const GB_GIANT_TYPES = {
0: { en: 'None', cn: '-' },
1: { en: 'Flandre', cn: '芙兰朵露' },
2: { en: 'Remilia', cn: '蕾米莉亚' },
3: { en: 'Sakuya', cn: '十六夜咲夜' },
4: { en: 'Meiling', cn: '红美铃' },
5: { en: 'Patchouli', cn: '帕秋莉' },
6: { en: 'Kaguya', cn: '辉夜' },
7: { en: 'Reisen', cn: '铃仙' },
8: { en: 'Tewi', cn: '因幡帝' },
9: { en: 'Eirin', cn: '永琳' },
10: { en: 'Mokou', cn: '妹红' },
11: { en: 'Kanako', cn: '神奈子' },
12: { en: 'Suwako', cn: '诹访子' },
13: { en: 'Sanae', cn: '早苗' },
14: { en: 'Aya', cn: '文' },
15: { en: 'Momiji', cn: '椛' },
16: { en: 'Satori', cn: '觉' },
17: { en: 'Koishi', cn: '恋' },
18: { en: 'Utsuho', cn: '空' },
19: { en: 'Yuugi', cn: '勇仪' },
20: { en: 'Rin', cn: '燐' },
21: { en: 'Reimu', cn: '灵梦' },
22: { en: 'Sumireko', cn: '堇子' },
23: { en: 'Kasen', cn: '华扇' },
24: { en: 'Aunn', cn: '阿吽' },
25: { en: 'Suika', cn: '萃香' },
26: { en: 'Byakuren', cn: '白莲' },
31: { en: 'Miko', cn: '神子' },
36: { en: 'Zanmu', cn: '斬梦' },
};
const GB_FORCE_NAMES = {
0: 'Common', 1: 'Remilia', 2: 'Kaguya', 3: 'Kanako', 4: 'Satori',
5: 'Reimu', 6: 'Byakuren', 7: 'Miko', 8: 'Zanmu',
9: 'Yuyuko', 10: 'Hecatia', 11: 'Megumu', 12: 'Cirno',
13: 'Yorihime', 14: 'Tenshi', 15: 'Ubame', 16: 'Seija', 17: 'Marisa',
};
const GB_CIV_NAMES = {
0: 'Common', 1: 'Egyptian', 2: 'French', 3: 'Germany', 4: 'Indian',
5: 'Norway', 6: 'Britain', 7: 'Persian', 8: 'Byzantine',
9: 'Sumerian', 10: 'Mayan', 11: 'Malian', 12: 'Greek',
13: 'Khmer', 14: 'Aztec', 15: 'Incan', 16: 'Mongolian', 17: 'Arabian',
};
// GiantType → portrait image filename mapping
const GB_HERO_PORTRAITS = {
1: 'EgyptianFlandre.png',
2: 'EgyptianRemilia.png',
3: 'EgyptianSakuya.png',
4: 'EgyptianMeiling.png',
5: 'EgyptianPatchouli.png',
6: 'FrenchKaguya.png',
7: 'FrenchReisen.png',
8: 'FrenchTewi.png',
9: 'FrenchEirin.png',
10: 'FrenchMokou.png',
11: 'GermanyKanako.png',
12: 'GermanySuwako.png',
13: 'GermanySanae.png',
14: 'GermanyAya.png',
15: 'GermanyMomiji.png',
16: 'IndianSatori.png',
17: 'IndianKoishi.png',
18: 'IndianUtsuho.png',
19: 'IndianYuugi.png',
20: 'IndianRin.png',
21: 'NorwayReimu.png',
26: 'BritishByakuren.png',
31: 'PersianMiko.png',
36: 'ByzantineZanmu.png',
};
const GB_TIER_LABELS = ['夯', '顶级', '人上人', 'NPC', '拉'];
const GB_TIER_COLORS = ['#ef4444', '#f59e0b', '#10b981', '#6b7280', '#3b82f6'];
// ── State ──
let gbVersions = []; // [{version, matchCount, selected}]
let gbCurrentPage = 1;
let gbPageSize = 20;
let gbTotalMatches = 0;
let gbLastMatches = [];
let gbInitialized = false;
// Hero balance state
let gbhHeroData = null; // API response
let gbhMetric = 'appearRate';
let gbhpPricingData = null; // Latest balance modeling output
let gbhpSort = 'finalDesc';
let gbhdData = null; // Static hero data from Dashboard export
let gbhdRendered = false;
let gbhuData = null; // Hero upgrade turn estimation
let gbhuRendered = false;
const GBHD_CLASS_ORDER = [1, 2, 3, 4, 5];
const GBHD_CLASS_NAMES = {
1: '王',
2: '后',
3: '相',
4: '马',
5: '车',
};
const GBHD_FORCE_ORDER = [1, 2, 3, 4, 5];
const GBHD_ROW_SPECS = [
{ key: 'task', label: '任务', type: 'task' },
{ key: 'hp', label: '生命', type: 'stat' },
{ key: 'attack', label: '攻击', type: 'stat' },
{ key: 'defense', label: '防御', type: 'stat' },
{ key: 'move', label: '移动', type: 'stat' },
{ key: 'range', label: '射程', type: 'stat' },
{ key: 'sight', label: '视野', type: 'stat' },
{ key: 'skills', label: '技能', type: 'skills' },
];
const GBHU_TASK_TYPE_NAMES = {
0: '累计承受伤害',
1: '累计造成伤害',
2: '累计击杀',
3: '累计治疗',
4: '开启遗迹',
5: '探索迷雾',
6: '占领城市',
7: '增加技能层数',
8: '技能生效',
9: '帝金币收益',
10: '技能掉层',
11: '遇见玩家',
12: '添加特殊地格',
13: '帝国金币收益',
14: '研发科技',
15: '生产/拥有单位',
16: '指定单位击杀',
17: '御神签极端结果',
18: '溅射伤害',
19: '指定技能单位死亡',
20: '指定单位探索',
21: '帝国成员开遗迹',
22: '占村/占城',
23: '造成或承受伤害',
24: '设置灵异珠',
25: '阿吽石化承伤',
};
const GBHU_SPEED_LABELS = {
fast: '偏快',
normal: '常规',
slow: '偏慢',
gated: '门槛',
};
// ── Init (lazy, called when panel activates) ──
function gbInit() {
if (gbInitialized) return;
gbInitialized = true;
// Sub-tab switching
const subTabs = document.getElementById('gb-sub-tabs');
if (subTabs) {
subTabs.addEventListener('click', e => {
const btn = e.target.closest('.sub-tab');
if (!btn) return;
subTabs.querySelectorAll('.sub-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const sub = btn.dataset.sub;
document.querySelectorAll('#panel-gamebalance .sub-panel').forEach(p => {
p.classList.toggle('active', p.id === 'sub-' + sub);
});
if (sub === 'gb-hero-data') {
gbhdLoadData();
}
if (sub === 'gb-hero-upgrade') {
gbhuLoadData();
}
if (sub === 'gb-hero' && !gbhpPricingData) {
gbhpLoadPricing();
}
});
}
// Populate dropdowns
populateUnitTypeDropdown();
populateGiantTypeDropdown();
populateLevelDropdown();
// Wire checkbox ↔ dropdown enable
wireFilterCheckbox('gb-filter-unittype-on', 'gb-filter-unittype');
wireFilterCheckbox('gb-filter-gianttype-on', 'gb-filter-gianttype');
wireFilterCheckbox('gb-filter-level-on', 'gb-filter-level');
// Load versions (shared between tabs)
loadVersions();
// Hero balance: metric radio switch
const metricBar = document.getElementById('gbh-metric-bar');
if (metricBar) {
metricBar.addEventListener('change', e => {
if (e.target.name === 'gbh-metric') {
gbhMetric = e.target.value;
if (gbhHeroData) gbhRenderTierGrid();
}
});
}
const pricingSort = document.getElementById('gbhp-sort');
if (pricingSort) {
pricingSort.addEventListener('change', e => {
gbhpSort = e.target.value;
gbhpRenderPricingTable();
});
}
if (document.getElementById('sub-gb-hero')?.classList.contains('active')) {
gbhpLoadPricing();
}
if (document.getElementById('sub-gb-hero-data')?.classList.contains('active')) {
gbhdLoadData();
}
if (document.getElementById('sub-gb-hero-upgrade')?.classList.contains('active')) {
gbhuLoadData();
}
}
function wireFilterCheckbox(checkId, selectId) {
const check = document.getElementById(checkId);
const sel = document.getElementById(selectId);
if (!check || !sel) return;
check.addEventListener('change', () => {
sel.disabled = !check.checked;
if (!check.checked) sel.value = '';
});
}
function populateUnitTypeDropdown() {
const sel = document.getElementById('gb-filter-unittype');
if (!sel) return;
sel.innerHTML = '<option value="">全部</option>';
for (const [id, info] of Object.entries(GB_UNIT_TYPES)) {
if (parseInt(id) === 0) continue;
const opt = document.createElement('option');
opt.value = id;
opt.textContent = `${info.cn} (${info.en})`;
sel.appendChild(opt);
}
}
function populateGiantTypeDropdown() {
const sel = document.getElementById('gb-filter-gianttype');
if (!sel) return;
sel.innerHTML = '<option value="">全部</option>';
for (const [id, info] of Object.entries(GB_GIANT_TYPES)) {
if (parseInt(id) === 0) continue;
const opt = document.createElement('option');
opt.value = id;
opt.textContent = `${info.cn} (${info.en})`;
sel.appendChild(opt);
}
}
function populateLevelDropdown() {
const sel = document.getElementById('gb-filter-level');
if (!sel) return;
sel.innerHTML = '<option value="">全部</option>';
for (let i = 0; i <= 5; i++) {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = 'Lv.' + i;
sel.appendChild(opt);
}
}
// ── Version loading ──
async function loadVersions() {
try {
const resp = await fetch('/api/oss/versions?t=' + Date.now());
if (!resp.ok) throw new Error('API error');
const data = await resp.json();
gbVersions = data.map(v => ({ ...v, selected: true }));
renderVersionList();
gbhRenderVersionList();
} catch (e) {
document.getElementById('gb-version-list').innerHTML =
'<span class="text-muted">无法加载版本数据,请先运行 "更新数据"</span>';
document.getElementById('gb-version-badge').textContent = '0 版本';
}
}
function renderVersionList() {
const container = document.getElementById('gb-version-list');
const badge = document.getElementById('gb-version-badge');
if (!container) return;
if (gbVersions.length === 0) {
container.innerHTML = '<span class="text-muted">未找到版本数据。请在 Unity 中运行 "Tools/OSS 导出 JSON (Dashboard)",然后点击 "更新数据"。</span>';
badge.textContent = '0 版本';
return;
}
let totalMatches = 0;
container.innerHTML = gbVersions.map((v, i) => {
totalMatches += v.matchCount;
return `<label class="gb-version-item">
<input type="checkbox" ${v.selected ? 'checked' : ''} onchange="gbToggleVersion(${i}, this.checked)">
<span class="gb-version-name">${v.version}</span>
<span class="gb-version-count">${v.matchCount} 局</span>
</label>`;
}).join('');
const selCount = gbVersions.filter(v => v.selected).length;
badge.textContent = `${selCount}/${gbVersions.length} 版本 (${totalMatches} 局)`;
}
function gbToggleVersion(index, checked) {
gbVersions[index].selected = checked;
_updateVersionBadges();
}
function gbSelectAllVersions(selectAll) {
gbVersions.forEach(v => v.selected = selectAll);
renderVersionList();
gbhRenderVersionList();
}
function _updateVersionBadges() {
const selCount = gbVersions.filter(v => v.selected).length;
const total = gbVersions.reduce((s, v) => s + (v.selected ? v.matchCount : 0), 0);
const txt = `${selCount}/${gbVersions.length} 版本 (${total} 局)`;
const b1 = document.getElementById('gb-version-badge');
const b2 = document.getElementById('gbh-version-badge');
if (b1) b1.textContent = txt;
if (b2) b2.textContent = txt;
}
// ── Search ──
async function gbSearch(page) {
if (page !== undefined) gbCurrentPage = page;
else gbCurrentPage = 1;
const selectedVersions = gbVersions.filter(v => v.selected).map(v => v.version);
if (selectedVersions.length === 0) {
showToast('请至少选择一个版本');
return;
}
// Build query params
const params = new URLSearchParams();
params.set('versions', selectedVersions.join(','));
params.set('page', gbCurrentPage);
params.set('pageSize', gbPageSize);
// Filters
const utOn = document.getElementById('gb-filter-unittype-on');
const utSel = document.getElementById('gb-filter-unittype');
if (utOn && utOn.checked && utSel && utSel.value) {
params.set('unitType', utSel.value);
}
const gtOn = document.getElementById('gb-filter-gianttype-on');
const gtSel = document.getElementById('gb-filter-gianttype');
if (gtOn && gtOn.checked && gtSel && gtSel.value) {
params.set('giantType', gtSel.value);
}
const lvOn = document.getElementById('gb-filter-level-on');
const lvSel = document.getElementById('gb-filter-level');
if (lvOn && lvOn.checked && lvSel && lvSel.value) {
params.set('level', lvSel.value);
}
// Show loading
const tbody = document.getElementById('gb-data-tbody');
if (tbody) tbody.innerHTML = '<tr><td colspan="8" class="gb-loading">查询中...</td></tr>';
try {
const resp = await fetch('/api/oss/matches?' + params.toString());
if (!resp.ok) throw new Error('API error');
const result = await resp.json();
gbTotalMatches = result.total;
gbLastMatches = result.matches;
gbCurrentPage = result.page;
gbPageSize = result.pageSize;
renderMatchTable();
renderPagination();
renderStats();
// Update badge
document.getElementById('gb-data-badge').textContent = `${gbTotalMatches}`;
document.getElementById('gb-stats-badge').textContent = `${gbTotalMatches}`;
} catch (e) {
if (tbody) tbody.innerHTML = '<tr><td colspan="8" class="gb-loading">查询失败: ' + e.message + '</td></tr>';
}
}
function gbResetFilters() {
['gb-filter-unittype-on', 'gb-filter-gianttype-on', 'gb-filter-level-on'].forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = false;
});
['gb-filter-unittype', 'gb-filter-gianttype', 'gb-filter-level'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.value = ''; el.disabled = true; }
});
}
// ── Rendering ──
function renderMatchTable() {
const tbody = document.getElementById('gb-data-tbody');
if (!tbody) return;
if (gbLastMatches.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="gb-loading">未找到符合条件的对局</td></tr>';
return;
}
const startIdx = (gbCurrentPage - 1) * gbPageSize;
tbody.innerHTML = gbLastMatches.map((m, i) => {
const ts = m.timestamp ? formatTimestamp(m.timestamp) : '--';
return `<tr class="gb-row" onclick="gbShowDetail('${m.file}')">
<td>${startIdx + i + 1}</td>
<td><span class="gb-tag gb-tag-version">${m.version}</span></td>
<td title="${m.steamId}">${m.steamId ? m.steamId.substring(0, 8) + '...' : '--'}</td>
<td>${m.totalTurns}</td>
<td>${m.damageCount}</td>
<td>${m.unitCount}</td>
<td>${m.playerCount}</td>
<td>${ts}</td>
</tr>`;
}).join('');
}
function renderPagination() {
const container = document.getElementById('gb-pagination');
if (!container) return;
const totalPages = Math.ceil(gbTotalMatches / gbPageSize);
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
// Prev
html += `<button class="gb-page-btn" ${gbCurrentPage <= 1 ? 'disabled' : ''} onclick="gbSearch(${gbCurrentPage - 1})">&laquo;</button>`;
// Page numbers (show max 7)
const range = getPageRange(gbCurrentPage, totalPages, 7);
for (const p of range) {
if (p === '...') {
html += '<span class="gb-page-dots">...</span>';
} else {
html += `<button class="gb-page-btn ${p === gbCurrentPage ? 'active' : ''}" onclick="gbSearch(${p})">${p}</button>`;
}
}
// Next
html += `<button class="gb-page-btn" ${gbCurrentPage >= totalPages ? 'disabled' : ''} onclick="gbSearch(${gbCurrentPage + 1})">&raquo;</button>`;
html += `<span class="gb-page-info">${gbCurrentPage}/${totalPages} 页, 共 ${gbTotalMatches} 条</span>`;
container.innerHTML = html;
}
function getPageRange(current, total, maxVisible) {
if (total <= maxVisible) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(maxVisible / 2);
let start = Math.max(1, current - half);
let end = Math.min(total, start + maxVisible - 1);
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
const pages = [];
if (start > 1) { pages.push(1); if (start > 2) pages.push('...'); }
for (let i = start; i <= end; i++) pages.push(i);
if (end < total) { if (end < total - 1) pages.push('...'); pages.push(total); }
return pages;
}
function renderStats() {
const container = document.getElementById('gb-stats-content');
if (!container) return;
if (gbTotalMatches === 0) {
container.innerHTML = '<div class="gb-stats-empty">无匹配数据</div>';
return;
}
// Compute aggregate stats from current page matches (basic overview)
let totalDmg = 0, totalUnits = 0, totalTurns = 0, totalPlayers = 0;
for (const m of gbLastMatches) {
totalDmg += m.damageCount;
totalUnits += m.unitCount;
totalTurns += m.totalTurns;
totalPlayers += m.playerCount;
}
const count = gbLastMatches.length || 1;
container.innerHTML = `
<div class="gb-stats-grid">
<div class="gb-stat-card">
<div class="gb-stat-value">${gbTotalMatches}</div>
<div class="gb-stat-label">匹配对局</div>
</div>
<div class="gb-stat-card">
<div class="gb-stat-value">${(totalTurns / count).toFixed(1)}</div>
<div class="gb-stat-label">平均回合</div>
</div>
<div class="gb-stat-card">
<div class="gb-stat-value">${(totalDmg / count).toFixed(1)}</div>
<div class="gb-stat-label">平均伤害事件</div>
</div>
<div class="gb-stat-card">
<div class="gb-stat-value">${(totalUnits / count).toFixed(1)}</div>
<div class="gb-stat-label">平均造兵数</div>
</div>
</div>`;
}
// ── Detail modal ──
async function gbShowDetail(file) {
try {
const resp = await fetch('/api/oss/match?file=' + encodeURIComponent(file));
if (!resp.ok) throw new Error('加载失败');
const data = await resp.json();
showMatchDetailModal(data, file);
} catch (e) {
showToast('加载对局详情失败: ' + e.message);
}
}
function showMatchDetailModal(data, file) {
// Remove existing modal
let existing = document.getElementById('gb-detail-modal');
if (existing) existing.remove();
const parts = file.split('/');
const version = parts[0] || '';
const steamId = parts[1] || '';
const modal = document.createElement('div');
modal.id = 'gb-detail-modal';
modal.className = 'gb-modal-overlay';
modal.onclick = e => { if (e.target === modal) modal.remove(); };
const damages = data.damages || [];
const addUnits = data.addUnits || [];
const learnTechs = data.learnTechs || [];
const turnStarts = data.onTurnStarts || [];
const gameEnds = data.matchGameEnds || [];
const playerEnds = data.playerGameEnds || [];
modal.innerHTML = `
<div class="gb-modal">
<div class="gb-modal-header">
<span class="gb-modal-title">对局详情 - ${version}</span>
<button class="gb-modal-close" onclick="this.closest('.gb-modal-overlay').remove()">&times;</button>
</div>
<div class="gb-modal-body">
<div class="gb-detail-meta">
<span><strong>版本:</strong> ${version}</span>
<span><strong>玩家:</strong> ${steamId}</span>
<span><strong>MemberId:</strong> ${data.memberId || '--'}</span>
</div>
<div class="gb-detail-stats">
<span>伤害: ${damages.length}</span>
<span>造兵: ${addUnits.length}</span>
<span>科技: ${learnTechs.length}</span>
<span>回合数据: ${turnStarts.length}</span>
<span>玩家: ${playerEnds.length}</span>
</div>
<h4>伤害事件 (前50条)</h4>
<div class="gb-detail-table-wrap">
<table class="gb-table gb-table-sm">
<thead><tr><th>回合</th><th>击杀</th><th>攻方单位</th><th>伤害</th><th>受方单位</th><th>承伤</th></tr></thead>
<tbody>
${damages.slice(0, 50).map(d => `<tr>
<td>${d.turn}</td>
<td>${d.isKill ? '<span class="gb-tag gb-tag-kill">击杀</span>' : ''}</td>
<td>${unitLabel(d.originUnitType, d.originGiantType, d.originLevel)}</td>
<td>${d.originDamage}</td>
<td>${unitLabel(d.targetUnitType, d.targetGiantType, d.targetLevel)}</td>
<td>${d.targetDamage}</td>
</tr>`).join('')}
</tbody>
</table>
</div>
<h4>造兵记录 (前50条)</h4>
<div class="gb-detail-table-wrap">
<table class="gb-table gb-table-sm">
<thead><tr><th>回合</th><th>单位</th><th>势力</th></tr></thead>
<tbody>
${addUnits.slice(0, 50).map(a => `<tr>
<td>${a.turn}</td>
<td>${unitLabel(a.unitType, a.giantType, a.level)}</td>
<td>${forceLabel(a.force)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>
</div>
</div>`;
document.body.appendChild(modal);
}
// ── Label helpers ──
function unitLabel(unitType, giantType, level) {
const ut = GB_UNIT_TYPES[unitType];
const gt = GB_GIANT_TYPES[giantType];
let label = ut ? ut.cn : `UnitType(${unitType})`;
if (giantType && gt && gt.cn !== '-') {
label = gt.cn;
}
if (level > 0) label += ' Lv.' + level;
return label;
}
function forceLabel(forceId) {
return GB_FORCE_NAMES[forceId] || `Force(${forceId})`;
}
function formatTimestamp(ts) {
// timestamp is a unix-ms string from filename
const num = parseInt(ts);
if (isNaN(num)) return ts;
const d = new Date(num);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function showToast(msg) {
// Reuse app.js toast if available
const toast = document.getElementById('toast');
if (toast) {
toast.textContent = msg;
toast.style.display = 'block';
toast.style.opacity = '1';
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.style.display = 'none', 300); }, 2000);
}
}
// ════════════════════════════════════════════
// Hero Balance Sub-module
// ════════════════════════════════════════════
function gbhRenderVersionList() {
const container = document.getElementById('gbh-version-list');
const badge = document.getElementById('gbh-version-badge');
if (!container) return;
if (gbVersions.length === 0) {
container.innerHTML = '<span class="text-muted">未找到版本数据</span>';
if (badge) badge.textContent = '0 版本';
return;
}
let totalMatches = 0;
container.innerHTML = gbVersions.map((v, i) => {
totalMatches += v.matchCount;
return `<label class="gb-version-item">
<input type="checkbox" ${v.selected ? 'checked' : ''} onchange="gbhToggleVersion(${i}, this.checked)">
<span class="gb-version-name">${v.version}</span>
<span class="gb-version-count">${v.matchCount} 局</span>
</label>`;
}).join('');
const selCount = gbVersions.filter(v => v.selected).length;
if (badge) badge.textContent = `${selCount}/${gbVersions.length} 版本 (${totalMatches} 局)`;
}
function gbhToggleVersion(index, checked) {
gbVersions[index].selected = checked;
// Update both badges
const selCount = gbVersions.filter(v => v.selected).length;
const total = gbVersions.reduce((s, v) => s + (v.selected ? v.matchCount : 0), 0);
const txt = `${selCount}/${gbVersions.length} 版本 (${total} 局)`;
const b1 = document.getElementById('gb-version-badge');
const b2 = document.getElementById('gbh-version-badge');
if (b1) b1.textContent = txt;
if (b2) b2.textContent = txt;
}
function gbhSelectAllVersions(selectAll) {
gbVersions.forEach(v => v.selected = selectAll);
renderVersionList();
gbhRenderVersionList();
}
async function gbhCalculate() {
const selectedVersions = gbVersions.filter(v => v.selected).map(v => v.version);
if (selectedVersions.length === 0) {
showToast('请至少选择一个版本');
return;
}
const grid = document.getElementById('gbh-tier-grid');
if (grid) grid.innerHTML = '<div class="gb-loading">正在统计...</div>';
try {
const params = new URLSearchParams();
params.set('versions', selectedVersions.join(','));
const resp = await fetch('/api/oss/hero-stats?' + params.toString());
if (!resp.ok) throw new Error('API error');
gbhHeroData = await resp.json();
const badge = document.getElementById('gbh-total-badge');
if (badge) badge.textContent = `${gbhHeroData.totalMatches}`;
gbhRenderTierGrid();
} catch (e) {
if (grid) grid.innerHTML = '<div class="gb-stats-empty">统计失败: ' + e.message + '</div>';
}
}
function gbhRenderTierGrid() {
const grid = document.getElementById('gbh-tier-grid');
if (!grid || !gbhHeroData) return;
const heroes = gbhHeroData.heroes;
if (!heroes || heroes.length === 0) {
grid.innerHTML = '<div class="gb-stats-empty">无英雄数据</div>';
return;
}
// Get metric values and sort descending
const metric = gbhMetric;
const sorted = [...heroes]
.filter(h => h.matchCount > 0)
.sort((a, b) => b[metric] - a[metric]);
if (sorted.length === 0) {
grid.innerHTML = '<div class="gb-stats-empty">所选版本无英雄出场数据</div>';
return;
}
// Divide into 5 tiers using quintiles
const tiers = [[], [], [], [], []]; // 夯, 顶级, 人上人, NPC, 拉
const tierSize = Math.ceil(sorted.length / 5);
sorted.forEach((h, i) => {
const tierIdx = Math.min(Math.floor(i / tierSize), 4);
tiers[tierIdx].push(h);
});
// Build metric label and format
const metricLabels = {
appearRate: { label: '出场率', suffix: '%', decimals: 1 },
avgDamage: { label: '平均伤害', suffix: '', decimals: 0 },
avgDeaths: { label: '平均死亡', suffix: '', decimals: 2 },
};
const mInfo = metricLabels[metric] || metricLabels.appearRate;
let html = '';
for (let t = 0; t < 5; t++) {
const tierHeroes = tiers[t];
if (tierHeroes.length === 0) continue;
html += `<div class="gbh-tier-row">
<div class="gbh-tier-label" style="background:${GB_TIER_COLORS[t]}">
${GB_TIER_LABELS[t]}
</div>
<div class="gbh-tier-heroes">`;
for (const h of tierHeroes) {
const gt = h.giantType;
const info = GB_GIANT_TYPES[gt] || { en: '?', cn: '?' };
const portrait = GB_HERO_PORTRAITS[gt] || '';
const val = typeof h[metric] === 'number'
? h[metric].toFixed(mInfo.decimals) + mInfo.suffix
: '--';
html += `<div class="gbh-hero-card" title="${info.cn} (${info.en}) - ${mInfo.label}: ${val}">
<div class="gbh-hero-portrait">
${portrait
? `<img src="assets/heroes/${portrait}" alt="${info.en}" loading="lazy">`
: `<div class="gbh-hero-placeholder">${info.cn[0]}</div>`}
</div>
<div class="gbh-hero-name">${info.cn}</div>
<div class="gbh-hero-value">${val}</div>
</div>`;
}
html += `</div></div>`;
}
grid.innerHTML = html;
}
// ════════════════════════════════════════════
// Hero Data Sub-module
// ════════════════════════════════════════════
async function gbhdLoadData(forceReload = false) {
const table = document.getElementById('gbhd-hero-table');
const badge = document.getElementById('gbhd-total-badge');
if (!table) return;
if (!forceReload && gbhdData) {
gbhdRender();
return;
}
table.innerHTML = '<div class="gb-loading">正在读取英雄属性与技能...</div>';
if (badge) badge.textContent = '加载中';
try {
const payload = await gbhdFetchHeroPayload();
const units = payload.units || [];
const skills = payload.skills || [];
const heroTasks = payload.heroes || [];
gbhdData = gbhdBuildData(units, skills, heroTasks);
gbhdInitFilters();
gbhdRender();
} catch (e) {
if (badge) badge.textContent = '读取失败';
table.innerHTML = `<div class="gb-stats-empty">英雄数据读取失败: ${gbEscapeHtml(e.message)}</div>`;
}
}
async function gbhdFetchHeroPayload() {
try {
const apiResp = await fetch('/api/gamebalance/hero-data?t=' + Date.now());
if (apiResp.ok) return apiResp.json();
} catch (_) {
// Static file usage falls back to exported JSON below.
}
const [unitsResp, skillsResp, heroesResp] = await Promise.all([
fetch('data/units.json?t=' + Date.now()),
fetch('data/skills.json?t=' + Date.now()),
fetch('data/heroes.json?t=' + Date.now()),
]);
if (!unitsResp.ok) throw new Error('units.json 读取失败');
if (!skillsResp.ok) throw new Error('skills.json 读取失败');
return {
source: 'data/*.json',
units: await unitsResp.json(),
skills: await skillsResp.json(),
heroes: heroesResp.ok ? await heroesResp.json() : [],
};
}
function gbhdBuildData(units, skills, heroTasks) {
const skillMap = new Map((skills || []).map(skill => [Number(skill.skillType), skill]));
const taskMap = new Map((heroTasks || []).map(hero => [Number(hero.giantType), hero]));
const grouped = new Map();
(units || [])
.filter(unit => Number(unit.unitType) === 14 || Number(unit.giantType) > 0)
.filter(unit => Number(unit.giantType) > 0 && Number(unit.unitLevel) >= 1 && Number(unit.unitLevel) <= 4)
.filter(unit => GBHD_FORCE_ORDER.includes(Number(unit.empire?.force) || 0))
.forEach(unit => {
const giantType = Number(unit.giantType);
if (!grouped.has(giantType)) {
grouped.set(giantType, {
giantType,
giantName: unit.giantName || '',
heroName: gbhdCleanHeroName(unit.name || unit.giantName || ''),
chessType: Number(unit.chessType) || 0,
className: GBHD_CLASS_NAMES[Number(unit.chessType)] || `职阶${unit.chessType || '-'}`,
forceId: Number(unit.empire?.force) || 0,
forceName: unit.empire?.forceName || '',
forceNameLocal: unit.empire?.forceNameLocal || '',
civId: Number(unit.empire?.civ) || 0,
civName: unit.empire?.civName || '',
civNameLocal: unit.empire?.civNameLocal || '',
levels: {},
tasks: taskMap.get(giantType)?.tasks || [],
});
}
const hero = grouped.get(giantType);
const level = Number(unit.unitLevel);
const skillIds = (unit.skillIds || []).map(id => Number(id)).filter(id => id > 0);
const rowSkills = skillIds.map(id => gbhdSkillInfo(id, skillMap)).filter(Boolean);
hero.levels[level] = {
level,
name: unit.name || '',
stats: {
hp: unit.maxHealth,
attack: unit.attack,
defense: unit.defense,
move: unit.moveRange,
range: unit.attackRange,
sight: unit.sightRange,
cost: unit.cost,
},
skillIds: rowSkills.map(skill => skill.id),
skills: rowSkills,
};
});
const heroes = [...grouped.values()].map(hero => {
const firstLevel = hero.levels[1] || Object.values(hero.levels)[0];
if (firstLevel?.name) hero.heroName = gbhdCleanHeroName(firstLevel.name);
hero.searchText = gbhdHeroSearchText(hero);
return hero;
});
heroes.sort((a, b) => {
const classCmp = GBHD_CLASS_ORDER.indexOf(a.chessType) - GBHD_CLASS_ORDER.indexOf(b.chessType);
if (classCmp !== 0) return classCmp;
const forceCmp = GBHD_FORCE_ORDER.indexOf(a.forceId) - GBHD_FORCE_ORDER.indexOf(b.forceId);
if (forceCmp !== 0) return forceCmp;
return (a.heroName || '').localeCompare(b.heroName || '', 'zh-Hans-CN');
});
return { heroes };
}
function gbhdInitFilters() {
if (!gbhdData || gbhdRendered) return;
gbhdRendered = true;
const classSelect = document.getElementById('gbhd-class-filter');
const forceSelect = document.getElementById('gbhd-force-filter');
if (classSelect) {
const classes = GBHD_CLASS_ORDER
.map(chessType => ({ chessType, name: GBHD_CLASS_NAMES[chessType] }))
.filter(cls => gbhdData.heroes.some(hero => hero.chessType === cls.chessType));
classSelect.insertAdjacentHTML('beforeend', classes
.map(cls => `<option value="${cls.chessType}">${gbEscapeHtml(cls.name)}</option>`)
.join(''));
classSelect.addEventListener('change', gbhdRender);
}
if (forceSelect) {
const forces = [...new Map(gbhdData.heroes.map(hero => [
hero.forceName,
{ forceName: hero.forceName, label: gbhdForceLabel(hero) },
])).values()]
.filter(force => force.forceName)
.sort((a, b) => a.label.localeCompare(b.label, 'zh-Hans-CN'));
forceSelect.insertAdjacentHTML('beforeend', forces
.map(force => `<option value="${gbEscapeAttr(force.forceName)}">${gbEscapeHtml(force.label)}</option>`)
.join(''));
forceSelect.addEventListener('change', gbhdRender);
}
const search = document.getElementById('gbhd-search');
if (search) search.addEventListener('input', gbhdRender);
}
function gbhdRender() {
const table = document.getElementById('gbhd-hero-table');
const badge = document.getElementById('gbhd-total-badge');
if (!table || !gbhdData) return;
const classFilter = document.getElementById('gbhd-class-filter')?.value || '';
const forceFilter = document.getElementById('gbhd-force-filter')?.value || '';
const query = (document.getElementById('gbhd-search')?.value || '').trim().toLowerCase();
const heroes = gbhdData.heroes.filter(hero => {
if (classFilter && String(hero.chessType) !== classFilter) return false;
if (forceFilter && hero.forceName !== forceFilter) return false;
if (query && !hero.searchText.toLowerCase().includes(query)) return false;
return true;
});
if (badge) badge.textContent = `${heroes.length}/${gbhdData.heroes.length} 英雄`;
if (heroes.length === 0) {
table.innerHTML = '<div class="gb-stats-empty">当前筛选条件下没有英雄</div>';
return;
}
const groups = GBHD_CLASS_ORDER
.map(chessType => ({
chessType,
className: GBHD_CLASS_NAMES[chessType],
heroes: heroes
.filter(hero => hero.chessType === chessType)
.sort((a, b) => GBHD_FORCE_ORDER.indexOf(a.forceId) - GBHD_FORCE_ORDER.indexOf(b.forceId)),
}))
.filter(group => group.heroes.length > 0);
table.innerHTML = groups.map(group => gbhdRenderClassTable(group)).join('');
}
function gbhdRenderClassTable(group) {
return `<section class="gbhd-class-block">
<div class="gbhd-class-title">
<span class="gbhd-class-pill">${gbEscapeHtml(group.className)}</span>
<span>${group.heroes.length} 名英雄</span>
</div>
<div class="gbhd-table-wrap">
<table class="gbhd-compare-table">
<thead>
<tr>
<th class="gbhd-level-head">等级</th>
<th class="gbhd-metric-head">项</th>
${group.heroes.map(hero => `<th class="gbhd-hero-head">
<div class="gbhd-hero-head-main">${gbEscapeHtml(gbhdForceLabel(hero))}</div>
<div class="gbhd-hero-head-sub">${gbEscapeHtml(hero.heroName)} · ${gbEscapeHtml(hero.giantName)}</div>
</th>`).join('')}
</tr>
</thead>
<tbody>
${[1, 2, 3, 4].map(level => gbhdRenderLevelRows(group.heroes, level)).join('')}
</tbody>
</table>
</div>
</section>`;
}
function gbhdRenderLevelRows(heroes, level) {
return GBHD_ROW_SPECS.map((spec, index) => {
const rowClass = [
`gbhd-row-${spec.type}`,
level % 2 === 0 ? 'gbhd-level-band-alt' : '',
].filter(Boolean).join(' ');
return `<tr class="${rowClass}">
${index === 0 ? `<th class="gbhd-level-cell" rowspan="${GBHD_ROW_SPECS.length}">Lv${level}</th>` : ''}
<th class="gbhd-metric-cell">${gbEscapeHtml(spec.label)}</th>
${heroes.map(hero => gbhdRenderMetricCell(hero, level, spec)).join('')}
</tr>`;
}).join('');
}
function gbhdRenderMetricCell(hero, level, spec) {
const row = hero.levels[level];
if (!row) return '<td class="gbhd-value-cell gbhd-missing">-</td>';
if (spec.type === 'task') {
return `<td class="gbhd-value-cell gbhd-task-value">${gbEscapeHtml(gbhdTaskText(hero, level))}</td>`;
}
if (spec.type === 'skills') {
return `<td class="gbhd-value-cell gbhd-skill-value">${gbhdRenderSkillsCell(hero, row, level)}</td>`;
}
const value = gbhdFormatNum((row.stats || {})[spec.key]);
return `<td class="gbhd-value-cell gbhd-stat-value">${gbEscapeHtml(value)}</td>`;
}
function gbhdRenderSkillsCell(hero, row, level) {
const prev = hero.levels[level - 1];
const skills = gbhdSkillDelta(row, prev)
.map(skill => `<span class="gbhd-skill-chip ${skill.isNew ? 'is-new' : ''} ${skill.hidden ? 'is-hidden' : ''}" title="${gbEscapeAttr(skill.desc || '')}">${gbEscapeHtml(skill.label)}</span>`)
.join('');
return `<div class="gbhd-skill-line">${skills || '<span class="gbhd-empty">无技能</span>'}</div>`;
}
function gbhdSkillDelta(row, prev) {
const prevIds = new Set(prev?.skillIds || []);
return (row.skills || []).map(skill => ({
...skill,
isNew: !prevIds.has(skill.id),
}));
}
function gbhdSkillInfo(id, skillMap) {
const skill = skillMap.get(id);
if (!skill && id > 100000) return null;
const name = (skill?.name || '').trim();
const desc = gbhdCleanMarkup(skill?.desc || '');
const fallbackFromDesc = gbhdExtractSkillTitle(desc);
return {
id,
label: name || fallbackFromDesc || `#${id}`,
desc: desc || (!skill ? '未在技能表中找到' : ''),
viewType: skill?.viewType || '',
hidden: Boolean(skill?.notShow || (!name && !fallbackFromDesc)),
};
}
function gbhdTaskText(hero, level) {
if (level <= 1) return '-';
const task = hero.tasks?.[level - 2];
if (!task) return '-';
const pieces = [];
if (task.param !== undefined && task.param !== null && task.param !== 0) pieces.push(String(task.param));
if (task.skillName) pieces.push(task.skillName);
const desc = gbhdCleanMarkup(task.desc || '');
if (desc) pieces.push(desc);
return pieces.join(' ');
}
function gbhdHeroSearchText(hero) {
const parts = [
hero.heroName,
hero.giantName,
hero.className,
hero.forceName,
hero.forceNameLocal,
hero.civName,
hero.civNameLocal,
gbhdForceLabel(hero),
];
Object.values(hero.levels || {}).forEach(row => {
parts.push(row.name);
(row.skills || []).forEach(skill => parts.push(skill.label, skill.desc));
});
(hero.tasks || []).forEach(task => parts.push(task.desc, task.skillName));
return parts.filter(Boolean).join(' ');
}
function gbhdCleanHeroName(name) {
return String(name || '')
.replace(/\s*Lv\.?\s*\d+\s*$/i, '')
.replace(/\s+$/g, '');
}
function gbhdCleanMarkup(text) {
return String(text || '')
.replace(/\*\*<([^>]+)>\*\*/g, '$1')
.replace(/\*\*/g, '')
.replace(/\{param\}/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function gbhdExtractSkillTitle(desc) {
const match = String(desc || '').match(/^([^:]{1,16})[:]/);
return match ? match[1].trim() : '';
}
function gbhdForceLabel(hero) {
const force = hero.forceNameLocal || GB_FORCE_NAMES[hero.forceId] || hero.forceName || '';
const civ = hero.civNameLocal || GB_CIV_NAMES[hero.civId] || hero.civName || '';
if (force && civ) return `${force} / ${civ}`;
return force || civ || '-';
}
function gbhdFormatNum(value) {
if (typeof value !== 'number' || !isFinite(value)) return '-';
return String(value).replace(/\.0$/, '');
}
// ════════════════════════════════════════════
// Hero Upgrade Turn Estimation Sub-module
// ════════════════════════════════════════════
async function gbhuLoadData(forceReload = false) {
const table = document.getElementById('gbhu-upgrade-table');
const badge = document.getElementById('gbhu-total-badge');
if (!table) return;
if (!forceReload && gbhuData) {
gbhuRender();
return;
}
table.innerHTML = '<div class="gb-loading">正在读取英雄升级任务...</div>';
if (badge) badge.textContent = '加载中';
try {
const payload = await gbhdFetchHeroPayload();
const units = payload.units || [];
const skills = payload.skills || [];
const heroTasks = payload.heroes || [];
const baseData = gbhdBuildData(units, skills, heroTasks);
gbhuData = gbhuBuildData(baseData.heroes, skills);
gbhuInitFilters();
gbhuRender();
} catch (e) {
if (badge) badge.textContent = '读取失败';
table.innerHTML = `<div class="gb-stats-empty">英雄升级估算读取失败: ${gbEscapeHtml(e.message)}</div>`;
}
}
function gbhuBuildData(heroes, skills) {
const skillMap = new Map((skills || []).map(skill => [Number(skill.skillType), skill]));
const rows = [];
(heroes || []).forEach(hero => {
const estimates = [1, 2, 3].map((taskIndex, idx) => {
const fromLevel = idx + 1;
const task = hero.tasks?.[idx];
return gbhuEstimateTask(hero, fromLevel, fromLevel + 1, task, skillMap);
});
const searchText = [
hero.heroName,
hero.giantName,
hero.forceName,
hero.forceNameLocal,
hero.civName,
hero.civNameLocal,
gbhdForceLabel(hero),
...estimates.flatMap(est => [est.taskName, est.taskText, est.logic, est.turnText, est.speedLabel]),
].filter(Boolean).join(' ');
rows.push({ ...hero, estimates, upgradeSearchText: searchText });
});
rows.sort((a, b) => {
const forceCmp = GBHD_FORCE_ORDER.indexOf(a.forceId) - GBHD_FORCE_ORDER.indexOf(b.forceId);
if (forceCmp !== 0) return forceCmp;
const classCmp = GBHD_CLASS_ORDER.indexOf(a.chessType) - GBHD_CLASS_ORDER.indexOf(b.chessType);
if (classCmp !== 0) return classCmp;
return (a.heroName || '').localeCompare(b.heroName || '', 'zh-Hans-CN');
});
return { heroes: rows };
}
function gbhuInitFilters() {
if (!gbhuData || gbhuRendered) return;
gbhuRendered = true;
const forceSelect = document.getElementById('gbhu-force-filter');
if (forceSelect) {
const forces = [...new Map(gbhuData.heroes.map(hero => [
hero.forceName,
{ forceName: hero.forceName, label: gbhdForceLabel(hero) },
])).values()]
.filter(force => force.forceName)
.sort((a, b) => a.label.localeCompare(b.label, 'zh-Hans-CN'));
forceSelect.insertAdjacentHTML('beforeend', forces
.map(force => `<option value="${gbEscapeAttr(force.forceName)}">${gbEscapeHtml(force.label)}</option>`)
.join(''));
forceSelect.addEventListener('change', gbhuRender);
}
const speedSelect = document.getElementById('gbhu-speed-filter');
if (speedSelect) speedSelect.addEventListener('change', gbhuRender);
const search = document.getElementById('gbhu-search');
if (search) search.addEventListener('input', gbhuRender);
}
function gbhuRender() {
const table = document.getElementById('gbhu-upgrade-table');
const badge = document.getElementById('gbhu-total-badge');
if (!table || !gbhuData) return;
const forceFilter = document.getElementById('gbhu-force-filter')?.value || '';
const speedFilter = document.getElementById('gbhu-speed-filter')?.value || '';
const query = (document.getElementById('gbhu-search')?.value || '').trim().toLowerCase();
const heroes = gbhuData.heroes.filter(hero => {
if (forceFilter && hero.forceName !== forceFilter) return false;
if (speedFilter && !hero.estimates.some(est => est.speed === speedFilter)) return false;
if (query && !hero.upgradeSearchText.toLowerCase().includes(query)) return false;
return true;
});
if (badge) badge.textContent = `${heroes.length}/${gbhuData.heroes.length} 英雄`;
if (heroes.length === 0) {
table.innerHTML = '<div class="gb-stats-empty">当前筛选条件下没有英雄升级估算</div>';
return;
}
table.innerHTML = `<div class="gbhu-table-wrap">
<table class="gb-table gbhu-table">
<thead>
<tr>
<th>帝国</th>
<th>英雄</th>
<th>Lv1 → Lv2</th>
<th>Lv2 → Lv3</th>
<th>Lv3 → Lv4</th>
</tr>
</thead>
<tbody>
${heroes.map(hero => gbhuRenderHeroRow(hero)).join('')}
</tbody>
</table>
</div>`;
}
function gbhuRenderHeroRow(hero) {
return `<tr>
<td>
<div class="gbhu-force">${gbEscapeHtml(gbhdForceLabel(hero))}</div>
<div class="gbhu-class">${gbEscapeHtml(hero.className || '-')}</div>
</td>
<td>
<div class="gbhp-hero-main">
<span class="gbhp-hero-name">${gbEscapeHtml(hero.heroName || hero.giantName || '-')}</span>
<span class="gbhp-hero-key">${gbEscapeHtml(hero.giantName || '')}</span>
</div>
</td>
${hero.estimates.map(est => gbhuRenderEstimateCell(est)).join('')}
</tr>`;
}
function gbhuRenderEstimateCell(est) {
return `<td class="gbhu-est-cell">
<div class="gbhu-est-head">
<span class="gbhu-turn">${gbEscapeHtml(est.turnText)}</span>
<span class="gbhu-speed gbhu-speed-${gbEscapeAttr(est.speed)}">${gbEscapeHtml(est.speedLabel)}</span>
</div>
<div class="gbhu-task">${gbEscapeHtml(est.taskText)}</div>
<div class="gbhu-logic">${gbEscapeHtml(est.logic)}</div>
</td>`;
}
function gbhuEstimateTask(hero, fromLevel, toLevel, task, skillMap) {
if (!task) {
return gbhuEstimateResult(null, fromLevel, toLevel, 0, 0, 'gated', '没有配置任务,无法升级。', '无任务');
}
const taskType = Number(task.taskContentType);
const param = Number(task.param) || 0;
const taskName = GBHU_TASK_TYPE_NAMES[taskType] || `任务类型 ${taskType}`;
const taskText = gbhuTaskText(task, taskName, skillMap);
const special = gbhuSpecialEstimate(hero, fromLevel, toLevel, task, skillMap, taskName, taskText);
if (special) return special;
const model = gbhuBaseModel(taskType, param);
return gbhuEstimateResult(task, fromLevel, toLevel, model.min, model.max, model.speed, model.logic, taskText, taskName);
}
function gbhuSpecialEstimate(hero, fromLevel, toLevel, task, skillMap, taskName, taskText) {
const gt = Number(hero.giantType);
const taskType = Number(task.taskContentType);
const param = Number(task.param) || 0;
if (gt === 6 && taskType === 7) {
const perTurn = fromLevel >= 3 ? 6 : 3;
const min = Math.ceil(param / perTurn);
const max = Math.ceil(param / Math.max(1, perTurn - 1));
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
`按辉夜每回合用永夜返给周围友军叠满月估算Lv${fromLevel}常见每次影响${perTurn - 1}-${perTurn}个目标。`,
taskText, taskName);
}
if (gt === 7 && taskType === 8) {
const min = Math.ceil(param / 1.5);
const max = Math.ceil(param / 1);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
'按铃仙每回合主动参战并靠幻视调率约1-1.5次有效创造幻象估算。',
taskText, taskName);
}
if (gt === 7 && taskType === 7) {
const min = Math.ceil(param / 4);
const max = Math.ceil(param / 3);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'slow',
'按战地协同标的每回合约施加3-4层估算需要持续围绕同一目标打配合。',
taskText, taskName);
}
if (gt === 8 && taskType === 9) {
const min = Math.ceil(param / 4);
const max = Math.ceil(param / 3);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
'按帝每回合通过攻击、击杀或死亡收益约3-4金币估算真实速度取决于能否持续找战斗。',
taskText, taskName);
}
if (gt === 10 && taskType === 23) {
const min = Math.ceil(param / 16);
const max = Math.ceil(param / 12);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
'妹红既计造成也计承受伤害,按主动换血/自爆相关玩法每回合约12-16点累计估算。',
taskText, taskName);
}
if (gt === 5 && taskType === 10) {
const perTurn = fromLevel >= 3 ? 6 : 4;
const min = Math.ceil(param / perTurn);
const max = Math.ceil(param / Math.max(1, perTurn - 1));
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
`按帕秋莉每回合消耗${perTurn - 1}-${perTurn}层魔力石估算,取决于能否连续攻击或治疗。`,
taskText, taskName);
}
if (gt === 11 && taskType === 14) {
return gbhuEstimateResult(task, fromLevel, toLevel, 5, 7, 'gated',
'按前期完成5项科技估算通常受科技节奏限制约第5-7回合可达成。',
taskText, taskName);
}
if (gt === 12 && taskType === 15) {
return gbhuEstimateResult(task, fromLevel, toLevel, 0, 1, 'gated',
'需要已有御射宫司大人Lv4。若神奈子已达Lv4基本可立即完成否则完全受神奈子升级节奏锁定。',
taskText, taskName);
}
if (gt === 12 && taskType === 16) {
const min = Math.ceil(param / 2);
const max = Math.ceil(param / 1);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'slow',
'按御射宫司大人每回合1-2次击杀估算需要先有高等级蛇且持续参战。',
taskText, taskName);
}
if (gt === 13 && taskType === 7) {
const perTurn = fromLevel >= 3 ? 6 : 4;
const min = Math.ceil(param / perTurn);
const max = Math.ceil(param / Math.max(1, perTurn - 2));
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
`按早苗每回合通过神风/治疗给友军施加乘风${perTurn - 2}-${perTurn}层估算。`,
taskText, taskName);
}
if (gt === 13 && taskType === 17) {
const expectedActions = Math.ceil(param / 0.25);
return gbhuEstimateResult(task, fromLevel, toLevel, expectedActions, expectedActions + 3, 'slow',
'普通御神签大吉或大凶合计20%且3次非极端后下次强制极端按平均约4次行动出1次极端估算。',
taskText, taskName);
}
if (gt === 14 && taskType === 18) {
const min = Math.ceil(param / 18);
const max = Math.ceil(param / 12);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
'按文的路径/踩踏溅射每回合约12-18点有效溅射伤害估算敌方站位密集时偏快。',
taskText, taskName);
}
if (gt === 16 && taskType === 7 && Number(task.skillParam) === 210) {
const min = Math.ceil(param / 5);
const max = Math.ceil(param / 3);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
'按觉每回合通过攻击/窥视等来源施加3-5层恐惧估算。',
taskText, taskName);
}
if (gt === 16 && taskType === 7 && Number(task.skillParam) === 213) {
const min = Math.ceil(param / 2);
const max = Math.ceil(param / 1);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'slow',
'心理创伤来源更窄按每回合1-2次有效施加估算。',
taskText, taskName);
}
if (gt === 20 && taskType === 7) {
const min = Math.ceil(param / 5);
const max = Math.ceil(param / 3);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
'按燐通过收集尸骸并转化粗钝身/金刚身每回合约3-5层估算。',
taskText, taskName);
}
if (gt === 22 && taskType === 24) {
const min = Math.ceil(param / 1);
const max = Math.ceil(param / 0.8);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
'按堇子设置灵异珠每回合约1个估算若需要走位找空地则取上限。',
taskText, taskName);
}
if (gt === 24 && taskType === 25) {
const min = Math.ceil(param / 3);
const max = Math.ceil(param / 2);
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'slow',
'按阿吽进入吽形石守后每回合承受2-3次有效伤害估算需要主动让石化体吃火力。',
taskText, taskName);
}
return null;
}
function gbhuBaseModel(taskType, param) {
switch (Number(taskType)) {
case 0:
return gbhuModelFromRate(param, 10, 14, 'normal', '按英雄主动吃伤害每回合约10-14点承伤估算。');
case 1:
return gbhuModelFromRate(param, 10, 14, 'normal', '按英雄每回合一次有效攻击约10-14点造成伤害估算。');
case 2:
return gbhuModelFromRate(param, 1, 1.5, 'slow', '按英雄持续参战每回合约1-1.5次击杀估算。');
case 3:
return gbhuModelFromRate(param, 8, 12, 'normal', '按英雄每回合一次治疗或辅助治疗约8-12点回复量估算。');
case 4:
return gbhuModelFromRate(param, 0.4, 0.6, 'slow', '按英雄本人开遗迹约每2回合1处估算。');
case 5:
return gbhuModelFromRate(param, 7, 9, 'normal', '按探索型英雄每回合推进并揭开约7-9块迷雾估算。');
case 7:
case 8:
return gbhuModelFromRate(param, 2, 4, 'normal', '按对应技能每回合约触发2-4层/次估算,实际取决于目标密度和行动点。');
case 9:
case 13:
return gbhuModelFromRate(param, 5, 7, 'fast', '按帝国自然经济与战斗收益合计每回合约5-7金币估算。');
case 10:
return gbhuModelFromRate(param, 3, 5, 'normal', '按每回合消耗或掉落3-5层目标技能估算。');
case 12:
return gbhuModelFromRate(param, 1, 2, 'normal', '按每回合创建1-2个目标特殊地格估算。');
case 14:
return gbhuModelFromRate(param, 0.8, 1.2, 'gated', '按科技节奏约每回合0.8-1.2项估算,受研究线限制。');
case 15:
return gbhuModelFromRate(param, 0.8, 1.2, 'gated', '按目标单位生产/转化约每回合0.8-1.2个估算,受资源与前置单位限制。');
case 16:
return gbhuModelFromRate(param, 1, 1.5, 'slow', '按指定单位每回合约1-1.5次击杀估算。');
case 17:
return gbhuModelFromRate(param, 0.2, 0.25, 'slow', '按大吉/大凶约20%-25%出现率估算。');
case 18:
return gbhuModelFromRate(param, 10, 14, 'normal', '按每回合约10-14点溅射伤害估算。');
case 20:
return gbhuModelFromRate(param, 8, 10, 'normal', '按指定探索单位每回合揭开约8-10块迷雾估算。');
case 21:
return gbhuModelFromRate(param, 0.5, 0.8, 'normal', '按帝国成员合计开遗迹约每1-2回合1处估算。');
case 22:
return gbhuModelFromRate(param, 0.6, 1, 'gated', '按占1城=2进度、占1村=1进度前期通常约2-4回合完成。');
case 23:
return gbhuModelFromRate(param, 12, 16, 'normal', '按英雄主动交战造成或承受伤害合计每回合约12-16点估算。');
case 24:
return gbhuModelFromRate(param, 0.8, 1, 'normal', '按每回合约设置0.8-1个目标物估算。');
case 25:
return gbhuModelFromRate(param, 2, 3, 'slow', '按状态满足后每回合承受2-3次有效伤害估算。');
default:
return gbhuModelFromRate(param, 1, 1, 'gated', '未知任务类型暂按每回合1点进度粗估。');
}
}
function gbhuModelFromRate(param, lowRate, highRate, speed, logic) {
const min = Math.max(0, Math.ceil((Number(param) || 0) / Math.max(highRate, 0.01)));
const max = Math.max(min, Math.ceil((Number(param) || 0) / Math.max(lowRate, 0.01)));
return { min, max, speed, logic };
}
function gbhuEstimateResult(task, fromLevel, toLevel, min, max, speed, logic, taskText, taskName = '') {
const safeMin = Math.max(0, Math.ceil(min || 0));
const safeMax = Math.max(safeMin, Math.ceil(max || safeMin));
const turnText = safeMin === safeMax ? `${safeMin} 回合` : `${safeMin}-${safeMax} 回合`;
return {
fromLevel,
toLevel,
task,
taskName,
taskText,
minTurns: safeMin,
maxTurns: safeMax,
turnText,
speed,
speedLabel: GBHU_SPEED_LABELS[speed] || speed || '-',
logic,
};
}
function gbhuTaskText(task, taskName, skillMap) {
const desc = gbhdCleanMarkup(task?.desc || '');
const param = Number(task?.param) || 0;
const skill = skillMap.get(Number(task?.skillParam));
const suffix = skill?.name ? ` · ${skill.name}` : '';
return `${taskName}${param ? ` ${param}` : ''}${suffix}${desc ? `${desc}` : ''}`;
}
// ════════════════════════════════════════════
// Hero Pricing Model Sub-module
// ════════════════════════════════════════════
async function gbhpLoadPricing(forceRefresh = false) {
const table = document.getElementById('gbhp-pricing-table');
const badge = document.getElementById('gbhp-pricing-badge');
if (!table) return;
if (!forceRefresh && gbhpPricingData) {
gbhpRenderPricingTable();
return;
}
table.innerHTML = '<div class="gb-loading">正在读取最新定价模型...</div>';
if (badge) badge.textContent = '加载中';
try {
const resp = await fetch('/api/balance-modeling/hero-pricing/latest?t=' + Date.now());
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'API error');
gbhpPricingData = data;
gbhpRenderPricingMeta();
gbhpRenderPricingTable();
} catch (e) {
if (badge) badge.textContent = '读取失败';
table.innerHTML = `<div class="gb-stats-empty">定价模型读取失败: ${gbEscapeHtml(e.message)}</div>`;
}
}
function gbhpRenderPricingMeta() {
if (!gbhpPricingData) return;
const badge = document.getElementById('gbhp-pricing-badge');
const versionInfo = document.getElementById('gbhp-version-info');
const sourceInfo = document.getElementById('gbhp-source-info');
const summary = gbhpPricingData.summary || {};
if (badge) {
badge.textContent = `${gbhpPricingData.version || '--'} · ${summary.heroCount || 0} 英雄`;
}
if (versionInfo) {
const generated = gbhpPricingData.generatedOn ? ` · ${gbhpPricingData.generatedOn}` : '';
versionInfo.textContent = `${gbhpPricingData.version || '--'}${generated} · 总价 ${gbFormatNumber(summary.minTotal)}-${gbFormatNumber(summary.maxTotal)}`;
}
if (sourceInfo) {
sourceInfo.textContent = gbhpPricingData.source || '';
}
}
function gbhpRenderPricingTable() {
const table = document.getElementById('gbhp-pricing-table');
if (!table || !gbhpPricingData) return;
const heroes = gbhpSortedHeroes(gbhpPricingData.heroes || []);
if (heroes.length === 0) {
table.innerHTML = '<div class="gb-stats-empty">最新定价模型没有英雄数据</div>';
return;
}
const summary = gbhpPricingData.summary || {};
const maxTotal = summary.maxTotal || Math.max(...heroes.map(h => h.maxTotal || 0), 1);
table.innerHTML = `<div class="gbhp-table-wrap">
<table class="gb-table gbhp-table">
<thead>
<tr>
<th>英雄</th>
<th>职阶</th>
<th>Lv.1</th>
<th>Lv.2</th>
<th>Lv.3</th>
<th>Lv.4</th>
<th>峰值</th>
<th>技能摘要</th>
</tr>
</thead>
<tbody>
${heroes.map(hero => gbhpRenderHeroRow(hero, maxTotal)).join('')}
</tbody>
</table>
</div>`;
gbhpWirePricingTable(table);
}
function gbhpRenderHeroRow(hero, maxTotal) {
const levels = {};
(hero.levels || []).forEach(row => {
levels[row.level] = row;
});
return `<tr class="gbhp-hero-row" data-hero-key="${gbEscapeAttr(hero.giantTypeKey)}">
<td>
<div class="gbhp-hero-main">
<span class="gbhp-hero-name">${gbEscapeHtml(hero.hero || hero.giantTypeKey)}</span>
<span class="gbhp-hero-key">${gbEscapeHtml(hero.giantTypeKey || '')}</span>
</div>
</td>
<td><span class="gbhp-class-pill">${gbEscapeHtml(hero.chessType || '--')}</span></td>
${[1, 2, 3, 4].map(level => gbhpRenderLevelCell(hero.giantTypeKey, levels[level], maxTotal)).join('')}
<td><strong>${gbFormatNumber(hero.maxTotal)}</strong></td>
<td class="gbhp-skill-summary">${gbEscapeHtml(gbhpSkillSummary(hero))}</td>
</tr>`;
}
function gbhpRenderLevelCell(heroKey, row, maxTotal) {
if (!row) return '<td class="gbhp-empty-cell">--</td>';
const total = row.totalPrice || 0;
const width = Math.max(4, Math.min(100, maxTotal ? total / maxTotal * 100 : 0));
const title = `Lv.${row.level} 总价 ${gbFormatNumber(row.totalPrice)}`;
return `<td>
<button class="gbhp-price-cell" title="${gbEscapeAttr(title)}" data-hero-key="${gbEscapeAttr(heroKey)}" data-level="${row.level}">
<span class="gbhp-bar" style="width:${width.toFixed(1)}%"></span>
<span class="gbhp-value">${gbFormatNumber(row.totalPrice)}</span>
<span class="gbhp-breakdown">属 ${gbFormatNumber(row.attributePremium)} / 技 ${gbFormatNumber(row.skillPremium)}</span>
</button>
</td>`;
}
function gbhpWirePricingTable(container) {
container.querySelectorAll('.gbhp-hero-row').forEach(row => {
row.addEventListener('click', () => {
gbhpShowHeroDetail(row.dataset.heroKey || '');
});
});
container.querySelectorAll('.gbhp-price-cell').forEach(button => {
button.addEventListener('click', e => {
e.stopPropagation();
const level = parseInt(button.dataset.level || '', 10);
gbhpShowHeroDetail(button.dataset.heroKey || '', Number.isNaN(level) ? null : level);
});
});
}
function gbhpShowHeroDetail(heroKey, selectedLevel = null) {
if (!gbhpPricingData) return;
const hero = (gbhpPricingData.heroes || []).find(h => h.giantTypeKey === heroKey);
if (!hero) return;
const rows = selectedLevel
? (hero.levels || []).filter(row => row.level === selectedLevel)
: (hero.levels || []);
const titleLevel = selectedLevel ? ` Lv.${selectedLevel}` : '';
const modal = document.createElement('div');
modal.className = 'gb-modal-overlay';
modal.innerHTML = `
<div class="gb-modal gbhp-modal">
<div class="gb-modal-header">
<span class="gb-modal-title">${gbEscapeHtml(hero.hero || heroKey)}${titleLevel} · ${gbEscapeHtml(gbhpPricingData.version || '')}</span>
<button class="gb-modal-close" onclick="this.closest('.gb-modal-overlay').remove()">&times;</button>
</div>
<div class="gb-modal-body">
<div class="gb-detail-meta">
<span><strong>模型:</strong> ${gbEscapeHtml(gbhpPricingData.version || '--')}</span>
<span><strong>职阶:</strong> ${gbEscapeHtml(hero.chessType || '--')}</span>
<span><strong>Key:</strong> ${gbEscapeHtml(hero.giantTypeKey || '--')}</span>
</div>
<div class="gb-detail-stats">
<span>基础价: ${gbFormatNumber(gbhpPricingData.baseUnitPrice)}</span>
<span>英雄数: ${(gbhpPricingData.summary || {}).heroCount || 0}</span>
<span>数据源: ${gbEscapeHtml(gbhpPricingData.source || '--')}</span>
</div>
<h4>等级定价拆分</h4>
<div class="gb-detail-table-wrap gbhp-detail-table-wrap">
<table class="gb-table gb-table-sm gbhp-detail-table">
<thead>
<tr>
<th>等级</th>
<th>属性</th>
<th>属性基准</th>
<th>基础价</th>
<th>属性溢价</th>
<th>技能原价</th>
<th>技能基准</th>
<th>技能溢价</th>
<th>总价</th>
</tr>
</thead>
<tbody>
${rows.map(row => gbhpRenderDetailRow(row)).join('')}
</tbody>
</table>
</div>
<h4>技能计价</h4>
<div class="gbhp-skill-list">
${rows.map(row => gbhpRenderSkillBlock(row)).join('')}
</div>
</div>
</div>`;
document.body.appendChild(modal);
}
function gbhpRenderDetailRow(row) {
return `<tr>
<td>Lv.${row.level}</td>
<td>${gbhpStatsText(row.stats)}</td>
<td>${gbhpStatsText(row.baselines)}</td>
<td>${gbFormatNumber(row.baseUnitPrice)}</td>
<td>${gbFormatNumber(row.attributePremium)}</td>
<td>${gbFormatNumber(row.rawSkillPrice)}</td>
<td>${gbFormatNumber(row.skillPackageBaseline)}</td>
<td>${gbFormatNumber(row.skillPremium)}</td>
<td><strong>${gbFormatNumber(row.totalPrice)}</strong></td>
</tr>`;
}
function gbhpRenderSkillBlock(row) {
const skills = row.pricedSkills || [];
const skillHtml = skills.length
? skills.map(skill => `<span class="gbhp-skill-chip">${gbEscapeHtml(skill.label)}${skill.price !== null ? ` <strong>${gbFormatNumber(skill.price)}</strong>` : ''}</span>`).join('')
: '<span class="gbhp-skill-empty">无计价技能</span>';
return `<div class="gbhp-skill-block">
<div class="gbhp-skill-level">Lv.${row.level}</div>
<div class="gbhp-skill-chips">${skillHtml}</div>
</div>`;
}
function gbhpSortedHeroes(heroes) {
const sorted = [...heroes];
const finalTotal = hero => typeof hero.finalTotal === 'number' ? hero.finalTotal : -Infinity;
const maxTotal = hero => typeof hero.maxTotal === 'number' ? hero.maxTotal : -Infinity;
const heroName = hero => hero.hero || hero.giantTypeKey || '';
sorted.sort((a, b) => {
if (gbhpSort === 'finalAsc') return finalTotal(a) - finalTotal(b);
if (gbhpSort === 'maxDesc') return maxTotal(b) - maxTotal(a);
if (gbhpSort === 'className') return `${a.chessType || ''}${heroName(a)}`.localeCompare(`${b.chessType || ''}${heroName(b)}`, 'zh-Hans-CN');
if (gbhpSort === 'heroName') return heroName(a).localeCompare(heroName(b), 'zh-Hans-CN');
return finalTotal(b) - finalTotal(a);
});
return sorted;
}
function gbhpSkillSummary(hero) {
const levels = hero.levels || [];
const finalRow = levels[levels.length - 1];
if (!finalRow || !finalRow.pricedSkills || finalRow.pricedSkills.length === 0) return '无计价技能';
return finalRow.pricedSkills.map(skill => skill.label).slice(0, 3).join(' / ');
}
function gbhpStatsText(stats) {
if (!stats) return '--';
return `${gbFormatNumber(stats.hp)}${gbFormatNumber(stats.attack)}${gbFormatNumber(stats.defense)}${gbFormatNumber(stats.move)}${gbFormatNumber(stats.range)}`;
}
function gbFormatNumber(value, decimals = 2) {
if (typeof value !== 'number' || !isFinite(value)) return '--';
const fixed = value.toFixed(decimals);
return fixed.replace(/\.?0+$/, '');
}
function gbEscapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, ch => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ch]));
}
function gbEscapeAttr(value) {
return gbEscapeHtml(value).replace(/`/g, '&#96;');
}
// ── Lazy init via MutationObserver (same pattern as other modules) ──
(function () {
const panel = document.getElementById('panel-gamebalance');
if (!panel) return;
const observer = new MutationObserver(() => {
if (panel.classList.contains('active')) {
gbInit();
observer.disconnect();
}
});
observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
// Also init if already active
if (panel.classList.contains('active')) {
gbInit();
observer.disconnect();
}
})();