Monster debuff checker for Orna.RPG

Let you check monster's debuff in official Orna Codex page.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Monster debuff checker for Orna.RPG
// @namespace    http://tampermonkey.net/
// @version      1.4.2
// @description  Let you check monster's debuff in official Orna Codex page.
// @author       RplusTW
// @match        https://playorna.com/codex/raids/*/*
// @match        https://playorna.com/codex/bosses/*/*
// @match        https://playorna.com/codex/followers/*/*
// @match        https://playorna.com/codex/monsters/*/*
// @match        https://playorna.com/codex/classes/*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=playorna.com
// @require      https://cdn.jsdelivr.net/npm/[email protected]
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @connect      playorna.com
// @connect      orna.guide
// @run-at       document-end
// @license MIT
// ==/UserScript==

let autoInit = GM_getValue('autoInit') || false;

GM_registerMenuCommand('Auto Init. ?', toggleAutoInit, 'A');
function toggleAutoInit() {
	autoInit = window.confirm('Enable Auto initialize for debuff checker?')
	GM_setValue('autoInit', autoInit);
}


window.addEventListener('load', function() {
	if (autoInit) {
		init();
	} else {
		document.querySelector('.codex-page-icon')?.addEventListener('dblclick', init, { once: true, });
	}
}, false);


async function GET(url) {
	// console.log('GET', {url});
	return new Promise((resolve, reject) => {
		GM_xmlhttpRequest({
			method: 'GET',
			url: url,
			anonymous: true,
			onload: (response) => {
				resolve(response)
			},
			onerror: (response) => {
				reject(response)
			},
		});
	})
}

async function init() {
	let style = document.createElement('style');
	style.textContent = `.cus-checker{opacity:.3}.cus-checker:checked{opacity:.75}.cus-checker:checked+*{opacity:.5}`;
	document.head.append(style);
	collapsePage();
	let monster = await getEnInfo();
	linkToGuide(monster);
	initEffects(monster.effects);
	initStatus(monster.title);
}

function linkToGuide(monster) {
	let h1 = document.querySelector('h1.herotext');
	h1.innerHTML += ` <a href="https://orna.guide/search?searchstr=${monster.title || ''}" target="guide" title="check in orna.guide">🔍</a>`;
}

function collapsePage() {
	let tags = [...document.querySelectorAll('.codex-page h4, .codex-page h4 ~ div')];
	if (!tags.length) { return; }

	let box = null;

	let sections = tags.reduce((all, tag) => {
		if (tag.tagName === 'H4') {
			all[all.length] = [
				tag,
				[]
			];
		} else if (tag.tagName === 'DIV') {
			all[all.length - 1][1].push(genDetailsItem('', tag.innerHTML));
			tag.remove();
		}
		return all;
	}, []);

	sections.forEach(section => {
		section[0].insertAdjacentHTML(
			'beforebegin',
			genDetailsWrapper(
				genDetails(
					section[0].textContent.trim(),
					section[1].join('')
				)
			)
		);
		section[0].remove();
	});
}

function initEffects(effects) {
	let box = document.querySelector('.codex-page');
	let html = '';
	// console.log(effects);
	for (let prop in effects) {
		// effects[prop] = slimEffects(effects[prop]);
		html += genEffectHtml(prop, slimEffects(effects[prop]));
	};
	box.innerHTML += `<hr>${genDetailsWrapper(html)}`;
}

function genEffectHtml(prop, effects) {
	let items = effects.map(eff => genDetailsItem(eff[0], `
		<span>
			${eff[0]},
			<sub>${eff[1].join()}%</sub>
		</span>
	`)).join('');

	return genDetails(prop, items);
}

function initStatus(name) {
	let tier = Number(document.querySelector('.codex-page-meta')?.textContent?.match(/★(\d+)/)?.[1]);
	fetch('https://orna.guide/api/v1/monster', {
		method: 'post',
		body: JSON.stringify({
			name,
			tier: tier || null,
		}),
	}).then(r => r.json())
		.then(d => {
			if (d.length !== 1) {
				return;
			}
			// spawns
			let catas = [
				'immune_to',
				'immune_to_status',
				'resistant_to',
				'weak_to',
			];

			let data = d[0];
			let box = document.querySelector('.codex-page');

			if (data.immune_to_status) {
				data.immune_to_status.sort(sortStatus);
			}
			let html = genDetailsWrapper(
				catas.map(cata => !data[cata] ? '' :
					genDetails(
						_(cata),
						data[cata].map(i => genDetailsItem(_(i))).join(''),
					)
				).join('')
			)
			box.innerHTML += `<hr>${html}`;
		});
}

function sortStatus(a, b) {
	return statusOrder.findIndex(s => s === a) - statusOrder.findIndex(s => s === b);
}

function genStatusHtml(prop, effects) {
	let items = effects.map(eff => genDetailsItem(eff[0], `
		<span>
			${eff[0]},
			<sub>${eff[1].join()}%</sub>
		</span>
	`)).join('');

	return genDetails(prop, items);
}

function genDetailsItem(name, ctx = name) {
	return `
		<li>
			<label>
				<input type="checkbox" value="${name}" class="cus-checker">
				<span>${ctx}</span>
			</label>
		</li>
	`;
}

function genDetailsWrapper(html) {
	return `<div style="display:flex;justify-content:space-evenly;flex-wrap:wrap;">${html}</div>`
}

function genDetails(title, listHtml) {
	return `
		<details open style="width:fit-content;">
			<summary style="text-transform:capitalize;">
				${title}
			</summary>
			<ul style="list-style:none;text-align:start;padding:0;">${listHtml}</ul>
		</details>`
}

function slimEffects(effects) {
	let eff = effects.reduce((all, e) => {
		let o = e.match(/^(\D+)\s\((\d+)/) || [,e, 100];
		all[o[1]] = all[o[1]] || [];
		all[o[1]].push(+o[2]);
		return all;
	}, {});

	return Object.keys(eff).map(prop => {
		return [prop, [...new Set(eff[prop])].sort().reverse()];
	}).sort((a, b) => a[0].localeCompare(b[0]));
	return eff;
}

async function getEnInfo() {
	let html = await getUrlSource(getURL(location.href, 'en'));
	let h1 = parseHtml(html, 'h1.herotext');
	let title = h1[0].textContent.trim();
	let data = itemParse(html);
	let skillWord = skillWords.find(str => data[str]);
	let skills = itemParse(html)[skillWord];
	let effects = await parseSkillEffect(skills);
	return {
		title,
		skills,
		effects,
	};
}

async function parseSkillEffect(skills) {
	// getURL()
	let sources = await Promise.all(
		skills.map( skill => getUrlSource(getURL(skill.url)) )
	);

	let effects = skills.reduce((all, skill, index) => {
		skill.effect = itemParse(sources[index]);
		// console.log(skill.effect);
		for (let prop in skill.effect) {
			if (!all[prop]) {
				all[prop] = [];
			}
			let _es = skill.effect[prop].map(e => e.title);
			all[prop] = all[prop].concat(_es);
		}
		return all;
	}, {});

	return effects;
}

async function getUrlSource(url) {
	return GET(url).then(res => res.responseText)
	// return fetch(url).then(res => {
	// 	if (res.ok) {
	// 		return res.text();
	// 	}
	// 	window.open(res.url);
	// });
}

function parseHtml(html, selectoor = '') {
	let doc = document.implementation.createHTMLDocument();
	doc.body.innerHTML = html;
	return [...doc.querySelectorAll(selectoor)];
}

function itemParse(html) {
	let dataDivs = parseHtml(html, '.codex-page h4, .codex-page h4 ~ div');
	let data = dataDivs.reduce((all, div) => {
		if (div.tagName === 'H4') {
			let _prop = div.textContent.replace(/[::]/, '').trim().toLowerCase();
			all.currentProp = _prop;
			all[_prop] = all[_prop] || [];
		} else if (div.tagName === 'DIV') {
			let icon = div.querySelector('img')?.src;
			if (!div.querySelector('a[href^="/codex/classes/"]')) { // sucks learning-by
				all[all.currentProp].push({
					icon: div.querySelector('img')?.src,
					url: div.querySelector('a')?.href,
					title: div.textContent.trim(),
				});
			}
		}
		return all;
	}, {});
	delete data.currentProp;
	for (let i in data) {
		if (!data[i]?.length) {
			delete data[i];
		}
	}
	return data;
}

function getURL(url = location.href, lang = unsafeWindow.LANG_CODE) {
	if (lang === 'en') {
		let a = document.createElement('a');
		a.href = url;
		a.search = `lang=en`;
		// return `https://cors-anywhere.herokuapp.com/${a.href}`;
		// a.href = 'https://api.codetabs.com/v1/proxy?quest=' + a.href;
		return a.href;
		// return `https://api.allorigins.win/raw?url=${encodeURIComponent(a.href)}`;
	}
	return url;
}

const skillWords = [
  "Skills",
  "Compétences ",
  "Habilidades",
  "Fähigkeiten",
  "Умения",
  "技能",
  "Umiejętności",
  "Készségek",
  "Навички",
  "Abilità",
  "스킬",
  "スキル"
].map(str => str.toLowerCase());


let i18n = {
	langs: 					['zh', 'en', ],
	words: {
		'immune_to': 	['免疫', 'Immune'],
		'immune_to_status': ['狀態免疫', 'Status Immunity'],
		'resistant_to': ['抗性', 'Resists'],
		'weak_to': 		['弱點', 'Weakness'],
		'Water': 			['水',],
		'Fire': 			['火',],
		'Earthen': 		['土',],
		'Lightning': 	['雷',],
		'Dark': 			['暗',],
		'Dragon': 		['龍',],
		'Arcane': 		['奧',],
		'Holy': 			['聖',],
		'Physical': 	['物',],
		'Asleep': 		['入睡',],
		'Bleeding': 	['流血',],
		'Blight': 		['枯萎',],
		'Blind': 			['致盲',],
		'Burning': 		['燃燒',],
		'Confused': 	['迷惑',],
		'Cursed': 		['詛咒',],
		'Dark Sigil': ['暗之印記',],
		'Darkblight': ['暗黑疫病',],
		'Doom': 			['厄運'],
		'Foresight ↓': ['預知 ↓'],
		'Frozen': ['冰凍'],
		'Lulled': ['恍惚'],
		'Paralyzed': ['麻痺'],
		'Petrified': ['石化'],
		'Poisoned': ['中毒'],
		'Rot': ['腐敗'],
		'Starstruck': ['暈星'],
		'Stasis': ['停滯'],
		'Stunned': ['暈眩'],
		'Toxic': ['劇毒'],
		'Windswept': ['逆風'],
	},
};

const statusOrder = [
  'Poisoned',
  'Bleeding',
  'Burning',
  'Frozen',
  'Paralyzed',
  'Rot',
  'Cursed',
  'Toxic',
  'Blind',
  'Asleep',
  'Lulled',
  'Drenched',
  'Stunned',
  'Blight',
  'Petrified',
  'Stasis',
  'Doom',
  'Confused',
]

let langIndex = i18n.langs.findIndex(
		lang => lang === unsafeWindow.LANG_CODE?.replace(/-.+/, '')
	);

// get i18n
function _(key) {
	return i18n.words[key]?.[langIndex] || key;
}