天鳳牌理好形表示

天鳳牌理で一向聴の好形率を表示する

// ==UserScript==
// @name         天鳳牌理好形表示
// @name:zh      天凤牌理好形表示
// @name:zh-CN   天凤牌理好形表示
// @name:zh-TW   天鳳牌理好形表示
// @name:en      Tenhou-Pairi Kokei display
// @namespace    http://tanimodori.com/
// @version      0.1.0
// @description  天鳳牌理で一向聴の好形率を表示する
// @description:zh  在天凤牌理中显示好形率
// @description:zh-CN  在天凤牌理中显示好形率
// @description:zh-TW  在天鳳牌理中顯示好形率
// @description:en  Display Kokei percentage of ii-shan-ten in Tenhou-Pairi
// @author       Tanimodori
// @match        http://tenhou.net/2/*
// @match        https://tenhou.net/2/*
// @include      http://tenhou.net/2/*
// @include      https://tenhou.net/2/*
// @grant        none
// @license      MIT
// ==/UserScript==
(function() {
  "use strict";
  class MJ {
    static toArray(input) {
      const result = [];
      let head, tail;
      for (head = tail = 0; head < input.length; ++head) {
        if ("mpsz".indexOf(input[head]) === -1) {
          continue;
        }
        for (; tail < head; ++tail) {
          result.push(input[tail] + input[head]);
        }
        ++tail;
      }
      return result;
    }
    static toAka(input, force) {
      if (input[1] !== "m" && input[1] !== "p" && input[1] !== "s") {
        return input;
      }
      if (input[0] === "0" || input[0] === "5") {
        if (force === true) {
          return "0" + input[1];
        } else if (force === false) {
          return "5" + input[1];
        } else {
          return "0" === input[0] ? "5" + input[1] : "0" + input[1];
        }
      }
      return input;
    }
    static normalize(source) {
      return source.map((x) => MJ.toAka(x, false)).sort(MJ.compareTile);
    }
    static compareTile(a, b) {
      if (a[1] !== b[1]) {
        return a[1] < b[1] ? -1 : 1;
      } else {
        let aNum = a.charCodeAt(0);
        let bNum = b.charCodeAt(0);
        if (aNum === 48) {
          aNum = 53.5;
        }
        if (bNum === 48) {
          bNum = 53.5;
        }
        return aNum - bNum;
      }
    }
    static sub(source, ...tiles) {
      const result = [...source];
      for (const tile of tiles) {
        const index = result.findIndex((x) => MJ.toAka(x, false) === MJ.toAka(tile, false));
        if (index != -1) {
          result.splice(index, 1);
          continue;
        }
      }
      return result;
    }
    static remains(source, tile) {
      let result = 4;
      source.forEach((x) => {
        if (MJ.toAka(x, false) === MJ.toAka(tile, false)) {
          --result;
        }
      });
      return result;
    }
    static is13Orphans(source) {
      const orphanTiles = MJ.toArray("19m19p19s1234567z");
      if (source.length !== 14) {
        return false;
      }
      const subbed = MJ.sub(source, ...orphanTiles);
      return subbed.length === 1 && orphanTiles.indexOf(subbed[0]) !== -1;
    }
    static is7Pairs(source) {
      if (source.length !== 14) {
        return false;
      }
      const sorted = MJ.normalize(source);
      for (let i = 0; i < source.length - 1; ++i) {
        if (i % 2 === 0 && sorted[i] !== sorted[i + 1]) {
          return false;
        }
        if (i % 2 === 1 && sorted[i] === sorted[i + 1]) {
          return false;
        }
      }
      return true;
    }
    static splitSuits(source) {
      const result = {};
      for (const suit of "mpsz") {
        result[suit] = source.filter((x) => x[1] === suit);
      }
      return result;
    }
    static findSuitWithPair(suits) {
      let suitWithPair = null;
      for (const suit of "mpsz") {
        const lengthMod3 = suits[suit].length % 3;
        if (lengthMod3 === 2) {
          if (!suitWithPair) {
            suitWithPair = suit;
          } else {
            return null;
          }
        } else if (lengthMod3 === 1) {
          return null;
        }
      }
      return suitWithPair;
    }
    static _allMeldsLoop(inner, withPair) {
      if (inner.length === 0) {
        return true;
      }
      const tryComb = (comb, newWithPair = withPair) => {
        const subbed = MJ.sub(inner, ...comb);
        return subbed.length === inner.length - comb.length && MJ._allMeldsLoop(subbed, newWithPair);
      };
      if (withPair) {
        if (tryComb([inner[0], inner[0]], false)) {
          return true;
        }
      }
      if (inner.length >= 3) {
        if (tryComb([inner[0], inner[0], inner[0]])) {
          return true;
        }
        if (inner[0][0] < "8" && inner[0][1] !== "z") {
          const addToTile = (t, a) => String.fromCharCode(t.charCodeAt(0) + a) + t[1];
          if (tryComb([inner[0], addToTile(inner[0], 1), addToTile(inner[0], 2)])) {
            return true;
          }
        }
      }
      return false;
    }
    static allMelds(source, withPair) {
      if (source.length === 0) {
        return true;
      }
      withPair != null ? withPair : withPair = source.length % 3 === 2;
      if (withPair && source.length % 3 !== 2) {
        return false;
      }
      if (!withPair && source.length % 3 !== 0) {
        return false;
      }
      const sorted = MJ.normalize(source);
      return MJ._allMeldsLoop(sorted, withPair);
    }
    static isNormalWinHand(source) {
      if (source.length % 3 !== 2) {
        return false;
      }
      const suits = MJ.splitSuits(source);
      const suitWithPair = MJ.findSuitWithPair(suits);
      if (!suitWithPair) {
        return false;
      }
      for (const suitType of "mpsz") {
        if (!MJ.allMelds(suits[suitType], suitType === suitWithPair)) {
          return false;
        }
      }
      return true;
    }
    static isWinHand(source) {
      return MJ.is13Orphans(source) || MJ.is7Pairs(source) || MJ.isNormalWinHand(source);
    }
    static findWaitingTiles(source, predicate = MJ.isWinHand) {
      if (source.length % 3 !== 1)
        return [];
      const allTiles = MJ.toArray("123456789m123456789p123456789s1234567z");
      return allTiles.filter((tile) => MJ.remains(source, tile) > 0 && predicate([...source, tile]));
    }
  }
  const _Hand = class {
    constructor(tiles, predicate = "standard") {
      if (typeof tiles === "string") {
        this.tiles = MJ.toArray(tiles);
      } else {
        this.tiles = tiles;
      }
      this.children = [];
      this.predicate = predicate;
    }
    get full() {
      return this.tiles.length % 3 === 2;
    }
    get predicateFn() {
      if (typeof this.predicate === "function") {
        return this.predicate;
      } else if (this.predicate in _Hand.predicates) {
        return _Hand.predicates[this.predicate];
      } else {
        throw new Error(`Unknown predicate "${this.predicate}"`);
      }
    }
    discard(tile) {
      const result = new _Hand(MJ.sub(this.tiles, tile), this.predicate);
      result.parent = { hand: this, type: "discard", tile, tileCount: -1 };
      return result;
    }
    draw(tile) {
      const result = new _Hand([...this.tiles, tile], this.predicate);
      result.parent = { hand: this, type: "draw", tile, tileCount: -1 };
      return result;
    }
    remains(tile) {
      const deck = [...this.tiles];
      for (let cur = this.parent; cur; cur = cur.hand.parent) {
        if (cur.type === "discard") {
          deck.push(cur.tile);
        }
      }
      const result = MJ.remains(deck, tile);
      if (result < 0) {
        throw new Error(`tile "${tile}" has more than 4 tiles`);
      }
      return result;
    }
    isWinHand() {
      return this.predicateFn(this.tiles);
    }
    uniqueTiles(normalize = false) {
      const unique = (value, index, self) => self.indexOf(value) === index;
      let target = this.tiles;
      if (normalize) {
        target = MJ.normalize(target);
      }
      return target.filter(unique);
    }
    _xShantenPartial(childPredicate) {
      if (this.tiles.length % 3 !== 1) {
        return [];
      }
      this.children = [];
      for (const tile of _Hand.allTiles) {
        if (this.remains(tile) <= 0) {
          continue;
        }
        const child = this.draw(tile);
        if (childPredicate.call(child)) {
          this.children.push(child);
        }
      }
      return this.children;
    }
    _0ShantenPartial() {
      this.shanten = 0;
      return this._xShantenPartial(this.isWinHand);
    }
    _1ShantenPartial() {
      this.shanten = 1;
      return this._xShantenPartial(function() {
        return this._0ShantenFull().length !== 0;
      });
    }
    _xShantenFull(childPredicate) {
      if (this.tiles.length % 3 !== 2) {
        return [];
      }
      this.children = [];
      for (const tile of this.uniqueTiles(true)) {
        const child = this.discard(tile);
        if (childPredicate.call(child)) {
          this.children.push(child);
        }
      }
      return this.children;
    }
    _0ShantenFull() {
      this.shanten = 0;
      return this._xShantenFull(function() {
        return this._0ShantenPartial().length !== 0;
      });
    }
    _1ShantenFull() {
      this.shanten = 1;
      return this._xShantenFull(function() {
        return this._1ShantenPartial().length !== 0;
      });
    }
    markParentTileCount() {
      for (const child of this.children) {
        child.parent.tileCount = this.remains(child.parent.tile);
        child.markParentTileCount();
      }
    }
    mockShanten(shanten) {
      const lengthMod3 = this.tiles.length % 3;
      if (lengthMod3 === 0) {
        throw new Error(`Invalid tiles length ${shanten} to have shantens`);
      }
      this.shanten = shanten;
      if (shanten === 0) {
        const result = lengthMod3 === 2 ? this._0ShantenFull() : this._0ShantenPartial();
        this.markParentTileCount();
        return result;
      } else if (shanten === 1) {
        const result = lengthMod3 === 2 ? this._1ShantenFull() : this._1ShantenPartial();
        this.markParentTileCount();
        return result;
      } else {
        return [];
      }
    }
  };
  let Hand = _Hand;
  Hand.allTiles = MJ.toArray("123456789m123456789p123456789s1234567z");
  Hand.predicates = {
    standard: MJ.isWinHand,
    normal: MJ.isNormalWinHand
  };
  const shantenToNumber = (text) => {
    text = text.trim();
    if (text.indexOf("\u8074\u724C") !== -1) {
      return 0;
    } else if (text.indexOf("\u548C\u4E86") !== -1) {
      return -1;
    } else {
      const index = text.indexOf("\u5411\u8074");
      if (index !== -1) {
        return Number.parseInt(text.substring(0, index));
      }
    }
    throw new Error(`"${text}" is not a valid shanten text`);
  };
  const getShantenInfo = () => {
    const tehaiElement = document.querySelector("#tehai");
    if (!tehaiElement) {
      throw new Error("Cannot find #tehai element");
    }
    let result = null;
    tehaiElement.childNodes.forEach((node) => {
      var _a;
      if (!result && node.nodeType === node.TEXT_NODE) {
        const text = (_a = node.textContent) != null ? _a : "";
        const pattern = /(\d向聴|聴牌|和了)/gm;
        const matches = text.match(pattern);
        if (matches) {
          if (matches.length === 1) {
            const shanten = shantenToNumber(matches[0]);
            result = { standard: shanten, normal: shanten };
          } else if (matches.length === 2) {
            const standard = shantenToNumber(matches[0]);
            const normal = shantenToNumber(matches[1]);
            result = { standard, normal };
          }
        }
      }
    });
    if (!result) {
      throw new Error("Cannot find shanten info");
    }
    return result;
  };
  const getTiles = () => {
    const pattern = /([0-9][mps]|[1-7]z).gif/;
    const tiles = [];
    document.querySelectorAll("div#tehai > a > img").forEach((element) => {
      const match = element.src.match(pattern);
      if (match) {
        tiles.push(match[1]);
      }
    });
    return tiles;
  };
  const getQueryType = () => {
    const elementM2A = document.querySelector("#m2 > a");
    if (!elementM2A) {
      throw new Error("Cannot get query type");
    }
    const content = elementM2A.innerHTML;
    if (content === "\u6A19\u6E96\u5F62") {
      return "normal";
    } else if (content === "\u4E00\u822C\u5F62") {
      return "standard";
    }
    throw new Error("Cannot get query type");
  };
  const parseTextareaContent = (content) => {
    const pattern = /([0-9]+[mps]|[1-7]+z)+/gm;
    const matches = content.match(pattern);
    const result = {
      hand: [],
      waitings: []
    };
    if (matches) {
      result.hand = MJ.toArray(matches[0]);
      if (content.indexOf("\u6253") !== -1) {
        for (let i = 1; i < matches.length; i += 2) {
          result.waitings.push({ discard: matches[i], tiles: MJ.toArray(matches[i + 1]) });
        }
      } else {
        for (let i = 1; i < matches.length; ++i) {
          result.waitings.push({ tiles: MJ.toArray(matches[i]) });
        }
      }
    }
    return result;
  };
  const getTextareaTiles = () => {
    var _a;
    const textarea = document.querySelector("div#m2 > textarea");
    if (!textarea) {
      throw new Error("Cannot get textarea element");
    }
    const content = (_a = textarea.textContent) != null ? _a : "";
    return parseTextareaContent(content);
  };
  const getUIInfo = () => {
    const shanten = getShantenInfo();
    const hand = getTiles().sort(MJ.compareTile);
    const waitingInfo = getTextareaTiles();
    const result = {
      shanten,
      ...waitingInfo,
      hand,
      query: {
        type: getQueryType(),
        autofill: hand.length !== waitingInfo.hand.length
      }
    };
    return result;
  };
  const style = ".shanten-tile {\n  position: relative;\n}\n.shanten-tile .popup {\n  display: none;\n  width: 300px;\n  background-color: #ddd;\n  color: #fff;\n  text-align: center;\n  border-radius: 6px;\n  padding: 8px 0;\n  position: absolute;\n  z-index: 1;\n  top: 125%;\n  left: 50%;\n  margin-left: -150px;\n}\n.shanten-tile .popup::before {\n  content: '';\n  position: absolute;\n  top: calc(0% - 10px);\n  left: 50%;\n  margin-left: -5px;\n  border-width: 5px;\n  border-style: solid;\n  border-color: transparent transparent #ddd transparent;\n}\n.shanten-tile .popup.show {\n  visibility: visible;\n}\n.shanten-tile .popup .popup-tile img:last-of-type {\n  margin-left: 5px;\n}\n.shanten-tile .popup table {\n  text-align: initial;\n  margin-left: auto;\n  margin-right: auto;\n}\n.shanten-tile:hover .popup {\n  display: block;\n}\n";
  const injectCss = () => {
    const styleSheet = document.createElement("style");
    styleSheet.setAttribute("type", "text/css");
    styleSheet.innerHTML = style;
    document.head.appendChild(styleSheet);
  };
  function getElement(arg1, arg2) {
    let targetDocument;
    let spec;
    if (arg2) {
      targetDocument = arg1;
      spec = arg2;
    } else {
      targetDocument = document;
      spec = arg1;
    }
    return getElementInner(targetDocument, spec);
  }
  function getElementInner(document2, spec) {
    if (typeof spec === "string") {
      return document2.createTextNode(spec);
    }
    const isHTMLElement = (x) => {
      return "tagName" in x;
    };
    if (isHTMLElement(spec)) {
      return spec;
    }
    const element = document2.createElement(spec["_tag"]);
    for (const key in spec) {
      if (key === "_tag") {
        continue;
      } else if (key === "_class") {
        element.className = spec[key];
      } else if (key === "_innerHTML") {
        element.innerHTML = spec[key];
      } else if (key === "_children") {
        const value = spec[key];
        const children = value.map((x) => getElementInner(document2, x));
        element.append(...children);
      } else {
        element.setAttribute(key, spec[key]);
      }
    }
    return element;
  }
  function getShantenTable(config) {
    config.rows.sort(compareRow);
    const table = getElement({
      _tag: "table",
      cellpadding: "2",
      cellspacing: "0",
      _children: [
        {
          _tag: "tbody",
          _children: config.rows.map(getShantenRow)
        }
      ]
    });
    if (config.showHand) {
      return getElement({
        _tag: "div",
        _class: "popup",
        _children: [{ _tag: "div", _class: "popup-tile", _children: config.hand.map(getShantenRowTile) }, table]
      });
    } else {
      return table;
    }
  }
  function compareRow(a, b) {
    const aNum = a.tiles.reduce((acc, x) => acc + x.count, 0);
    const bNum = b.tiles.reduce((acc, x) => acc + x.count, 0);
    if (aNum != bNum) {
      return bNum - aNum;
    } else {
      if (a.discard && b.discard) {
        return MJ.compareTile(a.discard, b.discard);
      } else {
        return 0;
      }
    }
  }
  function getShantenRow(config) {
    const tiles = splitRowTiles(config);
    const tdData = [];
    if (config.discard) {
      tdData.push(["\u6253"]);
      tdData.push([getShantenRowTile(config.discard)]);
    }
    tdData.push([config.tenpai ? "\u5F85\u3061[" : "\u6478["]);
    let koukeiTotalCount;
    let gukeiTotalCount;
    const hasKoukei = tiles.koukei.length > 0;
    const hasGukei = tiles.gukei.length > 0;
    if (hasKoukei || hasGukei) {
      if (hasKoukei) {
        tdData.push(tiles.koukei.map(getShantenRowTile));
        koukeiTotalCount = tiles.koukei.reduce((a, x) => a + x.count, 0);
        tdData.push([`\u597D\u5F62${koukeiTotalCount}\u679A`]);
      } else {
        koukeiTotalCount = 0;
        tdData.push([]);
        tdData.push([]);
      }
      tdData.push([hasKoukei && hasGukei ? "+" : ""]);
      if (hasGukei) {
        tdData.push(tiles.gukei.map(getShantenRowTile));
        gukeiTotalCount = tiles.gukei.reduce((a, x) => a + x.count, 0);
        tdData.push([`\u611A\u5F62${gukeiTotalCount}\u679A`]);
      } else {
        gukeiTotalCount = 0;
        tdData.push([]);
        tdData.push([]);
      }
      tdData.push(["="]);
    }
    const hasOther = tiles.other.length > 0;
    if (hasOther) {
      tdData.push(tiles.other.map(getShantenRowTile));
    }
    const totalCount = config.tiles.reduce((a, x) => a + x.count, 0);
    tdData.push([`${totalCount}\u679A`]);
    if (koukeiTotalCount !== void 0) {
      const ratio = Math.round(100 * koukeiTotalCount / totalCount);
      tdData.push([`\uFF08\u597D\u5F62\u7387${ratio}%\uFF09`]);
    }
    tdData.push(["]"]);
    return getElement({
      _tag: "tr",
      _children: tdData.map((x) => ({
        _tag: "td",
        _children: x
      }))
    });
  }
  function splitRowTiles(config) {
    const koukei = [];
    const gukei = [];
    const other = [];
    for (const tile of config.tiles) {
      if (tile.type === "koukei") {
        koukei.push(tile);
      } else if (tile.type === "gukei") {
        gukei.push(tile);
      } else {
        other.push(tile);
      }
    }
    return { koukei, gukei, other };
  }
  function getShantenRowTile(config) {
    if (typeof config === "string") {
      return getElement({
        _tag: "img",
        src: `https://cdn.tenhou.net/2/a/${config}.gif`,
        border: "0",
        class: "D"
      });
    } else {
      const result = getElement({
        _tag: "span",
        _class: "shanten-tile",
        _children: [
          {
            _tag: "a",
            href: config.url,
            _children: [getShantenRowTile(config.tile)]
          }
        ]
      });
      if (config.child) {
        const childTable = getShantenTable(config.child);
        result.appendChild(childTable);
      }
      return result;
    }
  }
  const getTotalTileCounts = (children) => {
    return children.reduce((a, x) => a + x.parent.tileCount, 0);
  };
  const isKoukei = (hand) => {
    for (const child of hand.children) {
      const waitingCount = getTotalTileCounts(child.children);
      if (waitingCount > 4) {
        return true;
      }
    }
    return false;
  };
  const getHandUrl = (hand) => {
    const queryType = hand.predicateFn === Hand.predicates.standard ? "q" : "p";
    const queryStr = hand.tiles.join("");
    return `https://tenhou.net/2/?${queryType}=${queryStr}`;
  };
  const getRowConfigFromHand = (hand) => {
    const tiles = [];
    for (const child of hand.children) {
      let tileType = null;
      if (hand.shanten === 1) {
        tileType = isKoukei(child) ? "koukei" : "gukei";
      }
      const tileConfig = {
        type: tileType,
        tile: child.parent.tile,
        count: child.parent.tileCount,
        url: getHandUrl(child)
      };
      if (hand.shanten === 1) {
        const table = getTableConfigFromHand(child);
        table.showHand = true;
        tileConfig.child = table;
      }
      tiles.push(tileConfig);
    }
    return { discard: hand.parent.tile, tiles, tenpai: hand.shanten === 0 };
  };
  const getTableConfigFromHand = (hand) => {
    const config = {
      hand: hand.tiles,
      showHand: false,
      rows: hand.children.map(getRowConfigFromHand)
    };
    return config;
  };
  const run = () => {
    let uiInfo;
    try {
      uiInfo = getUIInfo();
    } catch (e) {
      return;
    }
    const queryType = uiInfo.query.type;
    if (uiInfo.shanten[queryType] !== 1) {
      return;
    }
    if (uiInfo.hand.length % 3 !== 2) {
      return;
    }
    const originalTable = document.querySelector("#m2 > table");
    if (!originalTable) {
      return;
    }
    injectCss();
    const hand = new Hand(uiInfo.hand, queryType);
    hand.mockShanten(1);
    const tableConfig = getTableConfigFromHand(hand);
    const table = getShantenTable(tableConfig);
    originalTable.after(table);
    originalTable.remove();
  };
  run();
})();