Mortal Bad Move Analyzer

bad move rate

// ==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);



})();