// ==UserScript==
// @name Mortal Bad Move Analyzer
// @namespace http://tampermonkey.net/
// @version 1.1
// @description bad move rate
// @author You
// @match https://mjai.ekyu.moe/*
// @icon https://mjai.ekyu.moe/favicon.ico
// @grant none
// ==/UserScript==
(function () {
'use strict';
const waitForElement = (selector, timeout = 10000) => {
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) return resolve(element);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
resolve(el);
}
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
observer.disconnect();
reject(`Timeout: ${selector} not found`);
}, timeout);
});
};
const analyzeBadMoves = () => {
const getTemplate = raw => {
const rawObj = { raw: raw };
return (...prm) => String.raw(rawObj, ...prm);
}
const getDahaiStr = (playerSelectHtmlStr) => {
return playerSelectHtmlStr.innerHTML
.replaceAll('<span class="role">プレイヤー: </span>', '')
.replaceAll('<span class="role">Player: </span>', '')
.replaceAll('<span class="role">작사: </span>', '')
.replaceAll('<span class="role">玩家: </span>', '')
.replaceAll('<svg class="tile">', '')
.replaceAll('<use class="face" href="', '')
.replaceAll('"></use>', '')
.replaceAll('</svg>', '')
.replaceAll(' ', '');
}
const getMortalSelects = (mortalSelectElements) => {
const mortalSelects = [];
mortalSelectElements.forEach((mortalSelect) => {
const recommendInt = mortalSelect.querySelectorAll('td')[2].querySelectorAll('span')[0].innerHTML;
const recommendFrac = mortalSelect.querySelectorAll('td')[2].querySelectorAll('span')[1].innerHTML;
mortalSelects.push({
dahai: getDahaiStr(mortalSelect.querySelectorAll('td')[0]),
recommendation: parseFloat(recommendInt + recommendFrac)
});
});
return mortalSelects;
}
const getTehaiState = (paiElements) => {
let tehai = [];
let draw = { kind: 'after-fu-ro', from: null, pai: null };
paiElements.forEach(pai => {
const from = pai.getAttribute('before')
if (from == '自摸 ' || from == 'Draw ' || from == '쯔모 ') {
draw = {
kind: 'tsumo',
from: 'jicha',
pai: pai.querySelector('svg > use').getAttribute('href')
}
} else if (from == '上家打 ' || from == 'Kamicha👈 cuts ' || from == '상가👈 타 ') {
draw = {
kind: 'fu-ro',
from: 'kamicha',
pai: pai.querySelector('svg > use').getAttribute('href')
}
} else if (from == '対面打 ' || from == 'Toimen👆 cuts ' || from == '대면👆 타 ' || from == '对家打 ') {
draw = {
kind: 'fu-ro',
from: 'toimen',
pai: pai.querySelector('svg > use').getAttribute('href')
}
} else if (from == '下家打 ' || from == 'Shimocha👉 cuts ' || from == '하가👉 타 ') {
draw = {
kind: 'fu-ro',
from: 'shimocha',
pai: pai.querySelector('svg > use').getAttribute('href')
}
} else {
tehai.push(pai.querySelector('svg > use').getAttribute('href'))
};
});
return {
tehai: tehai,
draw: draw
}
}
const pageBody = document.querySelector('body');
const hanchan = {};
hanchan.url = location.href;
const rounds = pageBody.querySelectorAll('section');
const paifuJsonStr = rounds[0].querySelector('div > details > iframe').getAttribute('src')
.replace(/https:\/\/tenhou\.net\/.*json=/, '');
const daniData = JSON.parse(paifuJsonStr);
hanchan.daniData = {
dan: daniData.dan,
name: daniData.name,
rate: daniData.rate,
sx: daniData.sx
};
hanchan.playerId = pageBody.querySelectorAll('details > dl > dd')[4].innerHTML;
hanchan.rounds = [];
rounds.forEach(round => {
const turns = [];
const turnDetails = Object.values(round.querySelectorAll('div > details')).slice(3);
turnDetails.forEach(turnDetail => {
const paiElements = turnDetail.querySelectorAll('ul > li');
const dahaiElement = Object.values(turnDetail.querySelectorAll('span')).filter(e => e.className == 'role')[0].parentElement;
const mortalSelectElements = turnDetail.querySelectorAll('details > table > tbody > tr');
const textFragment = turnDetail
.querySelector('summary').innerHTML
.replace('<span class="turn-info">', '')
.replaceAll(' ', ' ')
.replace('<span class="order-loss">', '')
.replaceAll('</span>', '')
turns.push({
summury: turnDetail.querySelector('summary').innerHTML.replace(/<span.*/, ''),
url: hanchan.url + '#:~:text=' + encodeURI(textFragment),
linkName: textFragment.replaceAll(' ', ' '),
tehaiState: getTehaiState(paiElements),
dahai: getDahaiStr(dahaiElement),
mortalSelects: getMortalSelects(mortalSelectElements)
});
});
hanchan.rounds.push({
id: round.querySelector('h1 > div > a').getAttribute('href'),
name: round.querySelector('h1 > div > a').innerHTML,
turns: turns
});
});
let dahaiCount = 0;
let missCount = 0;
let furoCount = 0;
const missList = pageBody.querySelectorAll('fieldset > label')[4];
if (document.querySelector("body > h1").innerHTML == 'Replay Examination') {
missList.insertAdjacentHTML('beforeend', '<br><span style="font-weight:700">Bad move:</span>');
} else {
missList.insertAdjacentHTML('beforeend', '<br><span style="font-weight:700">悪手リスト:</span>');
}
hanchan.rounds.forEach(round => {
round.turns.forEach(turn => {
let recommendation = 0;
turn.mortalSelects.forEach(mortalSelect => {
if (turn.dahai == mortalSelect.dahai) {
recommendation = mortalSelect.recommendation;
}
});
dahaiCount++;
if (turn.tehaiState.draw.kind != "fu-ro") {
if (recommendation <= 5) {
missCount++;
console.debug(`【悪手】局:${round.name}, 巡目:${turn.summury}, 打牌:${turn.dahai}, 推奨度:${recommendation}, ${round.id}:~:text=${turn.summury}`);
missList.insertAdjacentHTML('beforeend', `<br><a href="${turn.url}">${round.name}${turn.linkName}</a>`);
} else {
console.debug(`局:${round.name}, 巡目:${turn.summury}, 打牌:${turn.dahai}, 推奨度:${recommendation}`);
}
} else {
furoCount++;
}
});
});
const missRate = new Intl.NumberFormat('ja', { style: 'percent', minimumFractionDigits: 2 }).format(missCount / (dahaiCount - furoCount));
pageBody.querySelectorAll('details > dl > dt').forEach(metadata => {
if (metadata.innerHTML.includes('mjai-reviewer')) {
metadata.insertAdjacentHTML('beforebegin', `<dt>Bad move rate</dt><dd>${missRate}</dd>`);
}
});
console.debug(`打牌選択:${dahaiCount}, 副露数:${furoCount}, 悪手率:${missCount}, 悪手率:${missRate}`);
console.debug(hanchan);
};
waitForElement('details > dl > dt')
.then(() => {
setTimeout(analyzeBadMoves, 500); // 안정성을 위해 짧은 대기
})
.catch(console.error);
})();