Display Kokei percentage of ii-shan-ten in Tenhou-Pairi
// ==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 r###lt = []; let head, tail; for (head = tail = 0; head < input.length; ++head) { if ("mpsz".indexOf(input[head]) === -1) { continue; } for (; tail < head; ++tail) { r###lt.push(input[tail] + input[head]); } ++tail; } return r###lt; } 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 r###lt = [...source]; for (const tile of tiles) { const index = r###lt.findIndex((x) => MJ.toAka(x, false) === MJ.toAka(tile, false)); if (index != -1) { r###lt.splice(index, 1); continue; } } return r###lt; } static remains(source, tile) { let r###lt = 4; source.forEach((x) => { if (MJ.toAka(x, false) === MJ.toAka(tile, false)) { --r###lt; } }); return r###lt; } 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 r###lt = {}; for (const suit of "mpsz") { r###lt[suit] = source.filter((x) => x[1] === suit); } return r###lt; } 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 r###lt = new _Hand(MJ.sub(this.tiles, tile), this.predicate); r###lt.parent = { hand: this, type: "discard", tile, tileCount: -1 }; return r###lt; } draw(tile) { const r###lt = new _Hand([...this.tiles, tile], this.predicate); r###lt.parent = { hand: this, type: "draw", tile, tileCount: -1 }; return r###lt; } 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 r###lt = MJ.remains(deck, tile); if (r###lt < 0) { throw new Error(`tile "${tile}" has more than 4 tiles`); } return r###lt; } 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 r###lt = lengthMod3 === 2 ? this._0ShantenFull() : this._0ShantenPartial(); this.markParentTileCount(); return r###lt; } else if (shanten === 1) { const r###lt = lengthMod3 === 2 ? this._1ShantenFull() : this._1ShantenPartial(); this.markParentTileCount(); return r###lt; } 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 r###lt = null; tehaiElement.childNodes.forEach((node) => { var _a; if (!r###lt && 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]); r###lt = { standard: shanten, normal: shanten }; } else if (matches.length === 2) { const standard = shantenToNumber(matches[0]); const normal = shantenToNumber(matches[1]); r###lt = { standard, normal }; } } } }); if (!r###lt) { throw new Error("Cannot find shanten info"); } return r###lt; }; 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 r###lt = { hand: [], waitings: [] }; if (matches) { r###lt.hand = MJ.toArray(matches[0]); if (content.indexOf("\u6253") !== -1) { for (let i = 1; i < matches.length; i += 2) { r###lt.waitings.push({ discard: matches[i], tiles: MJ.toArray(matches[i + 1]) }); } } else { for (let i = 1; i < matches.length; ++i) { r###lt.waitings.push({ tiles: MJ.toArray(matches[i]) }); } } } return r###lt; }; 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 r###lt = { shanten, ...waitingInfo, hand, query: { type: getQueryType(), autofill: hand.length !== waitingInfo.hand.length } }; return r###lt; }; 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 r###lt = getElement({ _tag: "span", _class: "shanten-tile", _children: [ { _tag: "a", href: config.url, _children: [getShantenRowTile(config.tile)] } ] }); if (config.child) { const childTable = getShantenTable(config.child); r###lt.appendChild(childTable); } return r###lt; } } 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(); })();